bifroest/app/tools/service.py
2026-10-27 08:34:17 +01:00

303 lines
15 KiB
Python

"""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