"""Einstufung und Policy Engine für Tools (→ Konzept 10.1, 10.3, 10.7). Entscheidung für ein Tool in einem Workspace, in dieser Reihenfolge: 1. nicht eingestuft oder Definition geändert → DENY 2. Risikoklasse destructive → DENY (im MVP vollständig gesperrt) 3. globale Entscheidung des Plattform-Admins 4. Verschärfung des Workspace (nie lockernd) 5. Verschärfung für die konkrete Quelle des Aufrufs (nie lockernd) 6. Modus B: write_external → mindestens REQUIRE_APPROVAL Dazu, wer freigeben darf: Freigabestufe aus der globalen Einstufung (Workspace-Admin bzw. Freigeber oder globaler Approver) und Vier-Augen-Prinzip, das global, im Workspace oder je Quelle verlangt werden kann. Ausgewertet wird bei jedem einzelnen Aufruf, nicht nur beim Anbieten. """ from dataclasses import dataclass from sqlalchemy import select from sqlalchemy.dialects.postgresql import insert from sqlalchemy.orm import Session from app import audit from app.core.database import now from app.core.errors import Invalid, NotFound from app.data_sources.models import DataSource from app.identity import Principal from app.identity.admin import require as require_platform_admin from app.mcp import service as mcp from app.tools.models import ( ALLOW, DECISIONS, DENY, LEVELS, REQUIRE_APPROVAL, RISK_CLASSES, WORKSPACE_ADMIN, SourceToolPolicy, ToolDefinition, WorkspaceToolPolicy, ) from app.tools.registry import Tool, load from app.workspaces import service as workspaces from app.workspaces.models import Workspace # Vorschlag aus Konzept 10.7; übernommen wird er nur durch einen Admin DEFAULT_DECISION = {"read": ALLOW, "write_internal": ALLOW, "write_external": REQUIRE_APPROVAL, "destructive": DENY} def catalog(session: Session) -> dict[str, Tool]: """Alle bekannten Tools: eingebaute und die zuletzt entdeckten Tools aktiver MCP-Server.""" return load() | mcp.catalog_tools(session) def stricter(a: str, b: str) -> str: return max(a, b, key=DECISIONS.index) def sync(session: Session) -> list[ToolDefinition]: """Tools mit der Tabelle abgleichen; geänderte Definitionen verlieren ihre Einstufung.""" known = {d.name: d for d in session.scalars(select(ToolDefinition))} for tool in catalog(session).values(): definition = known.get(tool.name) if definition is None: # Mehrere Worker können ein neues Tool gleichzeitig sehen: nur einer legt es an session.execute(insert(ToolDefinition).values( name=tool.name, kind=tool.kind, description=tool.description, schema=tool.schema, hash=tool.hash, fingerprint=tool.fingerprint, suggested_risk=tool.suggested_risk, updated=now()) .on_conflict_do_nothing(index_elements=["name"])) continue if definition.fingerprint != tool.fingerprint: definition.fingerprint = tool.fingerprint session.add(definition) if definition.hash != tool.hash: if definition.classified_hash: audit.record(session, "tool.definition_changed", target_kind="tool", target_id=tool.name, old_hash=definition.hash, new_hash=tool.hash) definition.kind, definition.description, definition.schema = tool.kind, tool.description, tool.schema definition.hash, definition.suggested_risk, definition.updated = tool.hash, tool.suggested_risk, now() session.add(definition) session.flush() definitions = list(session.scalars(select(ToolDefinition).order_by(ToolDefinition.name))) inherit(session, definitions) return definitions def inherit(session: Session, definitions: list[ToolDefinition]) -> None: """Nicht eingestufte MCP-Tools übernehmen die Einstufung eines gleichen Tools desselben Dienstes (gleicher Fingerabdruck), z. B. query jeder weiteren Oracle-Verbindung.""" classified: dict[str, ToolDefinition] = {} for d in definitions: # Vorbild ist bevorzugt ein selbst eingestuftes Tool (z. B. das des Datenbank-Treibers) if d.fingerprint and d.classified and (d.fingerprint not in classified or not d.inherited_from): classified[d.fingerprint] = d for d in definitions: model = classified.get(d.fingerprint) if d.fingerprint else None if model is None or d.classified or model is d: continue copy_classification(model, d) audit.record(session, "tool.classification_inherited", target_kind="tool", target_id=d.name, inherited_from=model.name, risk_class=d.risk_class, decision=d.decision, hash=d.hash) def copy_classification(model: ToolDefinition, target: ToolDefinition) -> None: target.risk_class, target.decision = model.risk_class, model.decision target.approval_level, target.four_eyes = model.approval_level, model.four_eyes target.classified_hash, target.classified_by, target.classified_at = target.hash, model.classified_by, now() target.inherited_from = model.inherited_from or model.name def classify(session: Session, principal: Principal, name: str, risk_class: str, decision: str, approval_level: str | None = None, four_eyes: bool | None = None) -> None: """Einstufen; approval_level/four_eyes None = bisherigen Wert behalten.""" require_platform_admin(principal) if risk_class not in RISK_CLASSES or decision not in DECISIONS: raise Invalid("Unbekannte Risikoklasse oder Entscheidung.") if approval_level is not None and approval_level not in LEVELS: raise Invalid("Unbekannte Freigabestufe.") if risk_class == "destructive" and decision != DENY: raise Invalid("destructive bleibt im MVP gesperrt.") definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name)) if definition is None: raise NotFound if definition.kind.startswith("mcp:"): server = mcp.origins(session).get(definition.kind) if server is not None and server.template_id: raise Invalid("Tools einer Datenbank werden über ihren Treiber eingestuft.") definition.risk_class, definition.decision = risk_class, decision if approval_level is not None: definition.approval_level = approval_level if four_eyes is not None: definition.four_eyes = four_eyes definition.classified_hash, definition.classified_by, definition.classified_at = definition.hash, \ principal.user_id, now() definition.inherited_from = None audit.record(session, "tool.classified", user_id=principal.user_id, target_kind="tool", target_id=name, risk_class=risk_class, decision=decision, approval_level=definition.approval_level, four_eyes=definition.four_eyes, hash=definition.hash) # Gleiche Tools desselben Dienstes folgen der Einstufung if definition.fingerprint: for twin in session.scalars(select(ToolDefinition).where(ToolDefinition.fingerprint == definition.fingerprint, ToolDefinition.id != definition.id)): copy_classification(definition, twin) audit.record(session, "tool.classification_inherited", user_id=principal.user_id, target_kind="tool", target_id=twin.name, inherited_from=name, risk_class=risk_class, decision=decision, hash=twin.hash) def classify_suggested(session: Session, principal: Principal) -> int: """Alle nicht eingestuften eingebauten Tools nach dem Vorschlag einstufen (bewusste Admin-Aktion, auditiert). MCP-Tools nicht: ihre Annotations sind nur ein Vorschlag (→ Konzept 7.2).""" count = 0 for definition in sync(session): if not definition.classified and not definition.kind.startswith("mcp:"): classify(session, principal, definition.name, definition.suggested_risk, DEFAULT_DECISION[definition.suggested_risk]) count += 1 return count def check_tightening(definition: ToolDefinition, decision: str) -> None: if decision and decision not in DECISIONS: raise Invalid("Unbekannte Entscheidung.") if decision == ALLOW: raise Invalid("Eine Vorgabe kann nur verschärfen: „Freigabe nötig“ oder „sperren“.") if decision and definition.decision and stricter(decision, definition.decision) != decision: raise Invalid("Workspaces dürfen die globale Vorgabe nur verschärfen.") def definition_of(session: Session, name: str) -> ToolDefinition: definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name)) if definition is None: raise NotFound return definition def set_workspace_policy(session: Session, principal: Principal, workspace_id: int, name: str, decision: str, four_eyes: bool = False) -> None: """Workspace-Admin verschärft; '' ohne Vier-Augen-Prinzip setzt auf die globale Vorgabe zurück.""" workspaces.require_admin(session, principal, workspace_id) definition = definition_of(session, name) if definition.kind in mcp.foreign_kinds(session, workspace_id): raise NotFound check_tightening(definition, decision) policy = session.get(WorkspaceToolPolicy, (workspace_id, name)) if not decision and not four_eyes: if policy: session.delete(policy) else: policy = policy or WorkspaceToolPolicy(workspace_id=workspace_id, tool_name=name) policy.decision, policy.four_eyes = decision, four_eyes policy.updated_by, policy.updated = principal.user_id, now() session.add(policy) audit.record(session, "tool.workspace_policy_set", user_id=principal.user_id, workspace_id=workspace_id, target_kind="tool", target_id=name, decision=decision or "global", four_eyes=four_eyes) def set_source_policy(session: Session, principal: Principal, workspace_id: int, source_id: int, name: str, decision: str, four_eyes: bool = False) -> None: """Regel für ein Tool auf einer Quelle; nur Verschärfung. '' ohne Vier-Augen-Prinzip entfernt sie.""" workspaces.require_admin(session, principal, workspace_id) source = session.get(DataSource, source_id) if source is None or source.workspace_id != workspace_id: raise NotFound definition = definition_of(session, name) if definition.kind != source.kind: raise Invalid(f"{name} gehört nicht zu Quellen der Art {source.kind}.") check_tightening(definition, decision) policy = session.get(SourceToolPolicy, (source_id, name)) if not decision and not four_eyes: if policy: session.delete(policy) else: policy = policy or SourceToolPolicy(source_id=source_id, tool_name=name, workspace_id=workspace_id) policy.decision, policy.four_eyes = decision, four_eyes policy.updated_by, policy.updated = principal.user_id, now() session.add(policy) audit.record(session, "tool.source_policy_set", user_id=principal.user_id, workspace_id=workspace_id, target_kind="tool", target_id=name, source_id=source_id, decision=decision or "global", four_eyes=four_eyes) def source_policies(session: Session, workspace_id: int) -> list[SourceToolPolicy]: return list(session.scalars(select(SourceToolPolicy).where(SourceToolPolicy.workspace_id == workspace_id) .order_by(SourceToolPolicy.source_id, SourceToolPolicy.tool_name))) @dataclass(frozen=True) class Verdict: decision: str reason: str # Wer einen Antrag entscheiden darf level: str = WORKSPACE_ADMIN four_eyes: bool = False def evaluate(session: Session, workspace: Workspace, name: str, source_id: int | None = None) -> Verdict: """source_id: Quelle des konkreten Aufrufs; ohne sie gelten nur globale und Workspace-Vorgaben.""" definition = session.scalar(select(ToolDefinition).where(ToolDefinition.name == name)) tool = catalog(session).get(name) if definition is None or tool is None: return Verdict(DENY, "unbekanntes Tool") if not definition.classified or definition.hash != tool.hash: return Verdict(DENY, "nicht eingestuft") if definition.risk_class == "destructive": return Verdict(DENY, "destructive ist gesperrt") decision, reason, four_eyes = definition.decision, "globale Einstufung", definition.four_eyes policy = session.get(WorkspaceToolPolicy, (workspace.id, name)) if policy: four_eyes = four_eyes or policy.four_eyes if policy.decision and stricter(policy.decision, decision) != decision: decision, reason = policy.decision, "Vorgabe des Workspace" scoped = session.get(SourceToolPolicy, (source_id, name)) if source_id is not None else None if scoped and scoped.workspace_id == workspace.id: four_eyes = four_eyes or scoped.four_eyes if scoped.decision and stricter(scoped.decision, decision) != decision: decision, reason = scoped.decision, "Regel für diese Quelle" if (workspace.approval_mode == "all_writes" and definition.risk_class == "write_external" and stricter(decision, REQUIRE_APPROVAL) != decision): decision, reason = REQUIRE_APPROVAL, "Modus B" return Verdict(decision, reason, definition.approval_level, four_eyes) @dataclass(frozen=True) class ToolStatus: definition: ToolDefinition verdict: Verdict override: str available: bool # Workspace hat eine passende, getestete Quelle four_eyes_override: bool = False def overview(session: Session, workspace: Workspace, source_kinds: set[str]) -> list[ToolStatus]: overrides = {p.tool_name: p for p in session.scalars( select(WorkspaceToolPolicy).where(WorkspaceToolPolicy.workspace_id == workspace.id))} hidden = mcp.foreign_kinds(session, workspace.id) result = [] for d in sync(session): if d.kind in hidden: # Verbindungen anderer Workspaces und DB-Dienste continue policy = overrides.get(d.name) result.append(ToolStatus(d, evaluate(session, workspace, d.name), policy.decision if policy else "", d.kind in source_kinds, bool(policy and policy.four_eyes))) return result def offered(session: Session, workspace: Workspace, source_kinds: set[str], *, read_only: bool = False, keep: frozenset[str] = frozenset()) -> list[Tool]: """Tools, die das Modell in diesem Run sehen darf: erlaubte, und solche mit Freigabepflicht, die einen Freigabeantrag vorbereiten können. Nur solche aus Quellen und MCP-Servern des Workspace. read_only (Planungsmodus): nur Tools der Klasse read, dazu die in keep.""" tools = catalog(session) result = [] for status in overview(session, workspace, source_kinds): tool = tools.get(status.definition.name) decision = status.verdict.decision # Regeln je Quelle verschärfen nur; sie werden beim einzelnen Aufruf geprüft runnable = tool and ((decision == ALLOW and (tool.run or tool.prepare)) or (decision == REQUIRE_APPROVAL and tool.approvable)) if read_only and status.definition.risk_class != "read" and status.definition.name not in keep: continue if runnable and status.available: result.append(tool) return result