Freigabe-Hygiene (#631, #579, #609, #610) und JSON-Filter fuer WebFetch (#632)

#631: Derselbe Vorschlag (gleicher Agent, gleiches Werkzeug, gleiche Argumente unabhaengig
von Schluesselreihenfolge) wird nicht erneut angelegt - der Agent erfaehrt die bestehende
Nummer. Offene Vorschlaege verfallen nach 24 h (Status Expired).

#609: Werkzeuge koennen den Freigabeschluessel aus dem Inhalt bestimmen
(IAgentTool.StagingAktion). Database: SELECT/SHOW/DESCRIBE -> query, INSERT/UPDATE/DELETE
-> write, DDL -> schema; write und schema sind freigabepflichtig. Ausgefuehrt wird nach
Statement-Art, schreibende Statements melden die Zeilenzahl.

#579: Database-Vorpruefung (Zugriffsebene, Tabellenfreigabe, SqlGuard) - was das Werkzeug
ablehnen wuerde, kommt nicht mehr in die Freigabeliste.

#610: SqlGuard ueberspringt IF [NOT] EXISTS und erkennt DESCRIBE/DESC <tabelle>.

#632: WebFetch fetch mit filter liefert aus JSON/JSONP nur passende Eintraege; beim
Blaettern durch grosses JSON empfiehlt der Hinweis den filter.

Version 0.1.10.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-18 18:51:06 +02:00
co-authored by Claude Opus 5
parent f5731c50f8
commit f74e49ba35
18 changed files with 727 additions and 29 deletions
+98 -18
View File
@@ -22,7 +22,19 @@ public sealed class DatabaseTool : IAgentTool
{
public string Name => "Database";
public string Description => "Erlaubt den Zugriff auf SQL (MySQL, Postgres, MSSQL) und NoSQL (MongoDB) Datenbanken mit verschiedenen Zugriffsebenen.";
public string Description => """
Zugriff auf eine SQL-Datenbank (MySQL/MariaDB, Postgres, MSSQL) oder MongoDB.
SQL: Schicke jedes Statement mit action "query" und "sql" - lesend wie schreibend. Die Art
(lesen, schreiben, Struktur aendern) ergibt sich aus dem Statement selbst. Ein Statement je
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.
MongoDB: action query (filter), insert (document) in einer freigegebenen collection.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
@@ -54,6 +66,73 @@ 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.
/// </summary>
public string? Preflight(JsonElement input, AgentToolContext context)
{
var type = GetType(context);
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;
}
var sql = GetString(input, "sql");
if (string.IsNullOrWhiteSpace(sql))
return "'sql' ist erforderlich.";
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
return Verweigerung(inspection, accessLevel);
}
/// <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.
/// </summary>
public string? StagingAktion(JsonElement input)
{
var sql = GetString(input, "sql");
if (string.IsNullOrWhiteSpace(sql))
return null; // MongoDB: dort gilt das action-Argument
return SqlGuard.Inspect(sql, ["*"]).Operation switch
{
SqlOperation.Read => "query",
SqlOperation.Write => "write",
SqlOperation.Schema => "schema",
_ => null
};
}
private static string? Verweigerung(SqlInspection inspection, DatabaseAccessLevel accessLevel)
{
if (!inspection.IsValid)
return $"Sicherheitsfehler: {inspection.Error}";
return inspection.Operation switch
{
SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin =>
"Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.",
SqlOperation.Write when accessLevel == DatabaseAccessLevel.ReadOnly =>
"Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).",
_ => null
};
}
public async Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
@@ -62,7 +141,7 @@ public sealed class DatabaseTool : IAgentTool
var action = input.GetProperty("action").GetString()
?? throw new ArgumentException("'action' is required");
var type = context.ToolConfig.TryGetValue("type", out var t) ? t?.ToString()?.ToLowerInvariant() : null;
var type = GetType(context);
var connectionString = context.ToolConfig.TryGetValue("connectionString", out var cs) ? cs?.ToString() : null;
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(connectionString))
@@ -99,19 +178,8 @@ public sealed class DatabaseTool : IAgentTool
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
if (!inspection.IsValid)
return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}");
switch (inspection.Operation)
{
case SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin:
return ToolResult.Fail(
"Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
case SqlOperation.Write when accessLevel == DatabaseAccessLevel.ReadOnly:
return ToolResult.Fail(
"Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
}
if (Verweigerung(inspection, accessLevel) is { } verweigert)
return ToolResult.Fail(verweigert);
using DbConnection conn = type switch
{
@@ -125,7 +193,9 @@ public sealed class DatabaseTool : IAgentTool
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
if (action == "query")
// #609: Ob gelesen oder geschrieben wird, entscheidet das Statement. Vorher fuehrte
// action "query" ein INSERT per ExecuteReader aus und meldete "[]" statt der Zeilenzahl.
if (inspection.Operation == SqlOperation.Read)
{
using var reader = await cmd.ExecuteReaderAsync(ct);
var results = new List<Dictionary<string, object>>();
@@ -143,7 +213,9 @@ public sealed class DatabaseTool : IAgentTool
else
{
var affected = await cmd.ExecuteNonQueryAsync(ct);
return ToolResult.Ok($"{affected} Zeilen betroffen.");
return ToolResult.Ok(inspection.Operation == SqlOperation.Schema
? $"Strukturänderung ausgeführt ({affected} Zeilen betroffen)."
: $"{affected} Zeilen betroffen.");
}
}
@@ -186,7 +258,15 @@ public sealed class DatabaseTool : IAgentTool
}
}
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
private static string? GetType(AgentToolContext context)
=> context.ToolConfig.TryGetValue("type", out var t) ? t?.ToString()?.ToLowerInvariant() : null;
private static string? GetString(JsonElement input, string name)
=> input.ValueKind == JsonValueKind.Object && input.TryGetProperty(name, out var v) && v.ValueKind == JsonValueKind.String
? v.GetString()
: null;
private static DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
{
if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
{