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

105 lines
5.2 KiB
Python

"""Tool-Definitionen, Einstufung, Workspace-Verschärfungen und Tool-Aufrufe (→ Konzept 6.1, 10)."""
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import JSON, Boolean, ForeignKey, Index, Integer, String, Text, Uuid, false
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base, now
RISK_CLASSES = ("read", "write_internal", "write_external", "destructive")
ALLOW, REQUIRE_APPROVAL, DENY = "allow", "require_approval", "deny"
# Reihenfolge = Strenge; Workspaces dürfen nur nach rechts verschieben
DECISIONS = (ALLOW, REQUIRE_APPROVAL, DENY)
# Wer freigeben darf (→ Konzept 5.3, 10.5); Reihenfolge = Strenge
WORKSPACE_ADMIN, GLOBAL_APPROVER = "workspace_admin", "global_approver"
LEVELS = (WORKSPACE_ADMIN, GLOBAL_APPROVER)
class ToolDefinition(Base):
"""Bekanntes Tool mit Hash über Name, Beschreibung und Schema (→ Konzept 7.2).
Gilt nur als eingestuft, solange classified_hash dem aktuellen Hash entspricht.
Ändert sich die Definition, ist das Tool wieder DENY, bis ein Admin neu einstuft.
"""
__tablename__ = "tool_definitions"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(100), unique=True)
kind: Mapped[str] = mapped_column(String(20)) # Art der Quelle, z. B. git
description: Mapped[str] = mapped_column(Text)
schema: Mapped[dict[str, Any]] = mapped_column(JSON)
hash: Mapped[str] = mapped_column(String(64))
# MCP-Tools: gleicher Fingerabdruck = gleiches Tool desselben Dienstes; die Einstufung gilt für alle
fingerprint: Mapped[str | None] = mapped_column(String(64), index=True)
# Einstufung von einem gleichen Tool übernommen (dessen Name)
inherited_from: Mapped[str | None] = mapped_column(String(100))
suggested_risk: Mapped[str] = mapped_column(String(20))
# Einstufung durch den Plattform-Admin
risk_class: Mapped[str | None] = mapped_column(String(20))
decision: Mapped[str | None] = mapped_column(String(20))
classified_hash: Mapped[str | None] = mapped_column(String(64))
# Freigabestufe und Vier-Augen-Prinzip für Anträge dieses Tools
approval_level: Mapped[str] = mapped_column(String(20), default=WORKSPACE_ADMIN, server_default=WORKSPACE_ADMIN)
four_eyes: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
classified_by: Mapped[int | None] = mapped_column(ForeignKey("users.id"))
classified_at: Mapped[datetime | None]
updated: Mapped[datetime] = mapped_column(default=now)
@property
def classified(self) -> bool:
return self.classified_hash == self.hash and self.decision is not None
class WorkspaceToolPolicy(Base):
"""Verschärfung je Workspace; wird beim Auswerten nie lockernd angewendet."""
__tablename__ = "workspace_tool_policies"
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id", ondelete="CASCADE"), primary_key=True)
tool_name: Mapped[str] = mapped_column(String(100), primary_key=True)
# Leer = Entscheidung wie global (z. B. nur Vier-Augen-Prinzip verschärft)
decision: Mapped[str] = mapped_column(String(20))
four_eyes: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
updated_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
updated: Mapped[datetime] = mapped_column(default=now)
class SourceToolPolicy(Base):
"""Verschärfung für ein Tool auf einer bestimmten Quelle, z. B. commit_change auf dem Produktiv-Repository
nur mit Freigabe. Wie Workspace-Vorgaben nie lockernd."""
__tablename__ = "source_tool_policies"
source_id: Mapped[int] = mapped_column(ForeignKey("data_sources.id", ondelete="CASCADE"), primary_key=True)
tool_name: Mapped[str] = mapped_column(String(100), primary_key=True)
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id", ondelete="CASCADE"), index=True)
decision: Mapped[str] = mapped_column(String(20))
four_eyes: Mapped[bool] = mapped_column(Boolean, default=False)
updated_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
updated: Mapped[datetime] = mapped_column(default=now)
class ToolCall(Base):
"""Jeder Tool-Aufruf eines Runs, auch abgewiesene (→ Konzept 8.3, 13)."""
__tablename__ = "tool_calls"
__table_args__ = (Index("ix_tool_calls_run_id", "run_id"),)
id: Mapped[int] = mapped_column(primary_key=True)
run_id: Mapped[uuid.UUID] = mapped_column(Uuid, ForeignKey("task_runs.id", ondelete="CASCADE"))
workspace_id: Mapped[int] = mapped_column(ForeignKey("workspaces.id", ondelete="CASCADE"))
call_id: Mapped[str] = mapped_column(String(200))
tool: Mapped[str] = mapped_column(String(200))
arguments: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
decision: Mapped[str] = mapped_column(String(20))
status: Mapped[str] = mapped_column(String(20)) # ok | error | denied
# Gekürzt; das vollständige Ergebnis steht im Verlauf der Nachricht
output: Mapped[str] = mapped_column(Text, default="")
# Ergebnistabelle lesender Abfragen (Spalten, Zeilen) für Anzeige, CSV und Diagramm
result: Mapped[dict[str, Any] | None] = mapped_column(JSON(none_as_null=True))
duration_ms: Mapped[int] = mapped_column(Integer, default=0)
created: Mapped[datetime] = mapped_column(default=now)