Freigaben buendeln statt Weckflut (#643), Freigabemodell ueber Werkzeugrechte (#641)

#643: Entscheidungen wecken den Agenten nur noch bei Ablehnung oder gescheiterter
Ausfuehrung, gesammelt in einer Aufgabe je Agent (5 min Ruhefenster), frisch per
@new:agent statt im fortgefuehrten Chatverlauf. Eine einmalige Aufgabe, deren Lauf
wegen Budget oder Herunterfahren gar nicht stattfand, bleibt faellig (Wiedervorlage
nach 30 min) statt mit gesetztem Marker liegenzubleiben.

#641: Rechte stehen in der Werkzeugkonfiguration des Agenten (erlaubt/verboten).
Erlaubtes laeuft, Nicht-Erlaubtes wird einzeln vorgelegt statt verweigert, Verbotenes
gar nicht erst. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare
Dauerfreigabe je Agent/Werkzeug/Operation; nach drei gleichartigen Freigaben schlaegt
die Oberflaeche sie vor. Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig.
Database: Operation ist das SQL-Schluesselwort; die eigene Zugriffspruefung greift nur
noch ohne Freigabestelle (AgentToolContext.FreigabeGeprueft). WebUI: Rechte-Tabelle je
Werkzeug, Dauerfreigaben mit Widerruf.

Version 0.1.11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-19 14:37:44 +02:00
co-authored by Claude Opus 5
parent f74e49ba35
commit 2f0361278b
36 changed files with 1687 additions and 137 deletions
+40 -24
View File
@@ -30,8 +30,9 @@ public sealed class DatabaseTool : IAgentTool
Aufruf, keine SQL-Kommentare. Nur die freigegebenen Tabellen sind erreichbar.
Tabellenstruktur: "DESCRIBE <tabelle>" oder "SHOW COLUMNS FROM <tabelle>" - pruefe die
Spaltennamen, bevor du schreibst. Schreibende Statements liefern die Zahl betroffener Zeilen.
Je nach Einstellung werden schreibende oder strukturaendernde Statements zur Freigabe
vorgelegt statt sofort ausgefuehrt; liegt derselbe Vorschlag schon vor, lege ihn nicht erneut vor.
Welche Operationen (SELECT, INSERT, UPDATE, ALTER ...) du ohne Rueckfrage ausfuehren darfst,
legt deine Werkzeugkonfiguration fest. Andere werden einzeln zur Freigabe vorgelegt statt
sofort ausgefuehrt; liegt derselbe Vorschlag schon vor, lege ihn nicht erneut vor.
MongoDB: action query (filter), insert (document) in einer freigegebenen collection.
""";
@@ -67,9 +68,12 @@ public sealed class DatabaseTool : IAgentTool
""").RootElement.Clone();
/// <summary>
/// #579: Was das Werkzeug ohnehin ablehnen wuerde, kommt nicht in die Freigabeliste. Im
/// Testbetrieb legte ein Agent mit ReadOnly seine Test-INSERTs zur Freigabe vor - nach der
/// Freigabe scheiterten sie an genau dieser Pruefung.
/// #579: Was das Werkzeug ohnehin ablehnen wuerde, kommt nicht in die Freigabeliste -
/// unlesbares SQL, nicht freigegebene Tabellen und Collections.
///
/// <para>#641: Die Operation selbst (SELECT, INSERT, ALTER ...) prueft hier niemand mehr.
/// Was nicht erlaubt ist, entscheidet die Freigabestelle - und legt es einzeln vor, statt
/// es zu verweigern.</para>
/// </summary>
public string? Preflight(JsonElement input, AgentToolContext context)
{
@@ -77,16 +81,11 @@ public sealed class DatabaseTool : IAgentTool
if (string.IsNullOrWhiteSpace(type))
return null; // Konfigurationsfehler meldet die Ausfuehrung selbst
var accessLevel = GetAccessLevel(context);
if (type == "mongodb")
{
var action = GetString(input, "action");
var collection = GetString(input, "collection");
if (!string.IsNullOrWhiteSpace(collection) && !IsCollectionAllowed(collection, context))
return $"Sicherheitsfehler: Zugriff auf Collection '{collection}' ist nicht erlaubt.";
if (action is "insert" or "upsert" && accessLevel == DatabaseAccessLevel.ReadOnly)
return "Sicherheitsfehler: Schreibzugriff deaktiviert.";
return null;
}
@@ -95,34 +94,51 @@ public sealed class DatabaseTool : IAgentTool
return "'sql' ist erforderlich.";
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
return Verweigerung(inspection, accessLevel);
return inspection.IsValid ? null : $"Sicherheitsfehler: {inspection.Error}";
}
/// <summary>Die Operationen, die sich je Agent erlauben lassen (#641).</summary>
public IReadOnlyList<string> Operationen { get; } =
["select", "insert", "update", "delete", "replace", "create", "alter", "drop", "truncate"];
/// <summary>
/// #609: Der Freigabeschluessel folgt dem Statement, nicht dem action-Argument. Vorher lief
/// ein INSERT, das als action "insert" zur Freigabe lag, eine Sekunde spaeter als action
/// "query" ohne Freigabe durch.
/// #609/#641: Der Freigabeschluessel folgt dem Statement, nicht dem action-Argument - ein
/// INSERT ist "insert", auch wenn das Modell action "query" schickt.
/// </summary>
public string? StagingAktion(JsonElement input)
{
var sql = GetString(input, "sql");
if (string.IsNullOrWhiteSpace(sql))
return null; // MongoDB: dort gilt das action-Argument
if (!string.IsNullOrWhiteSpace(sql))
return SqlGuard.Operationsschluessel(sql);
return SqlGuard.Inspect(sql, ["*"]).Operation switch
return GetString(input, "action") switch
{
SqlOperation.Read => "query",
SqlOperation.Write => "write",
SqlOperation.Schema => "schema",
"query" => "select",
"insert" => "insert",
"upsert" => "update",
_ => null
};
}
private static string? Verweigerung(SqlInspection inspection, DatabaseAccessLevel accessLevel)
/// <summary>
/// Ohne <c>erlaubt</c> in der Konfiguration: Lesen laeuft, alles andere wird vorgelegt.
/// </summary>
public Core.Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary<string, object?> config)
=> operation == "select" ? Core.Staging.StagingDecision.Auto : Core.Staging.StagingDecision.Approve;
/// <summary>
/// Die eigene Rechtepruefung des Werkzeugs. Laeuft nur, wenn keine Freigabestelle den
/// Aufruf geprueft hat (<see cref="AgentToolContext.FreigabeGeprueft"/>) - dann gilt die
/// Zugriffsstufe als harte Grenze.
/// </summary>
private static string? Verweigerung(SqlInspection inspection, DatabaseAccessLevel accessLevel, bool freigabeGeprueft)
{
if (!inspection.IsValid)
return $"Sicherheitsfehler: {inspection.Error}";
if (freigabeGeprueft)
return null;
return inspection.Operation switch
{
SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin =>
@@ -178,7 +194,7 @@ public sealed class DatabaseTool : IAgentTool
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
if (Verweigerung(inspection, accessLevel) is { } verweigert)
if (Verweigerung(inspection, accessLevel, context.FreigabeGeprueft) is { } verweigert)
return ToolResult.Fail(verweigert);
using DbConnection conn = type switch
@@ -245,7 +261,7 @@ public sealed class DatabaseTool : IAgentTool
}
else if (action == "insert")
{
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
if (accessLevel == DatabaseAccessLevel.ReadOnly && !context.FreigabeGeprueft) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
var docJson = input.TryGetProperty("document", out var d) ? d.GetString() : throw new ArgumentException("'document' erforderlich.");
var doc = BsonDocument.Parse(docJson);
await collection.InsertOneAsync(doc, cancellationToken: ct);
@@ -253,7 +269,7 @@ public sealed class DatabaseTool : IAgentTool
}
else // upsert
{
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
if (accessLevel == DatabaseAccessLevel.ReadOnly && !context.FreigabeGeprueft) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
return ToolResult.Fail("Upsert für MongoDB noch nicht voll implementiert.");
}
}
@@ -82,6 +82,30 @@ public static class SqlGuard
@"^[\s`\[""]*(?<table>[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)",
RegexOptions.Compiled);
/// <summary>
/// Die Operation als Schluessel fuer die Rechte in der Werkzeugkonfiguration (#641):
/// lesende Statements (SELECT, SHOW, DESCRIBE, EXPLAIN, WITH) sind alle "select",
/// alle anderen ihr erstes Schluesselwort in Kleinschrift ("insert", "alter", ...).
/// <c>null</c>, wenn das Statement nicht lesbar ist.
/// </summary>
public static string? Operationsschluessel(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
return null;
var normalized = RemoveStringLiterals(sql);
var firstWord = normalized
.Split([' ', '\t', '\r', '\n', '(', ';'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return DetermineOperation(normalized) switch
{
SqlOperation.Read => "select",
SqlOperation.Write or SqlOperation.Schema => firstWord!.ToLowerInvariant(),
_ => null
};
}
public static SqlInspection Inspect(string? sql, IReadOnlyCollection<string> allowedTables)
{
if (string.IsNullOrWhiteSpace(sql))