Files
ClawdDotNet/src/ClawdDotNet.Tools.Database/DatabaseTool.cs
T
RichardandClaude Opus 5 2f0361278b 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>
2026-09-19 14:37:44 +02:00

323 lines
13 KiB
C#

using System.Data;
using System.Data.Common;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MongoDB.Bson;
using MongoDB.Driver;
using MySqlConnector;
using Npgsql;
namespace ClawdDotNet.Tools.Database;
public enum DatabaseAccessLevel
{
ReadOnly,
ReadWrite,
Admin
}
public sealed class DatabaseTool : IAgentTool
{
public string Name => "Database";
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.
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.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["query", "insert", "upsert"],
"description": "Die auszuführende Aktion"
},
"sql": {
"type": "string",
"description": "Das auszuführende SQL Statement (nur für SQL-Typen)"
},
"collection": {
"type": "string",
"description": "Der Name der Collection (nur für MongoDB)"
},
"filter": {
"type": "string",
"description": "JSON-Filter (nur für MongoDB)"
},
"document": {
"type": "string",
"description": "JSON-Dokument zum Einfügen/Update (nur für MongoDB)"
}
},
"required": ["action"]
}
""").RootElement.Clone();
/// <summary>
/// #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)
{
var type = GetType(context);
if (string.IsNullOrWhiteSpace(type))
return null; // Konfigurationsfehler meldet die Ausfuehrung selbst
if (type == "mongodb")
{
var collection = GetString(input, "collection");
if (!string.IsNullOrWhiteSpace(collection) && !IsCollectionAllowed(collection, context))
return $"Sicherheitsfehler: Zugriff auf Collection '{collection}' ist nicht erlaubt.";
return null;
}
var sql = GetString(input, "sql");
if (string.IsNullOrWhiteSpace(sql))
return "'sql' ist erforderlich.";
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
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/#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 SqlGuard.Operationsschluessel(sql);
return GetString(input, "action") switch
{
"query" => "select",
"insert" => "insert",
"upsert" => "update",
_ => null
};
}
/// <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 =>
"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,
CancellationToken ct)
{
var action = input.GetProperty("action").GetString()
?? throw new ArgumentException("'action' is required");
var type = GetType(context);
var connectionString = context.ToolConfig.TryGetValue("connectionString", out var cs) ? cs?.ToString() : null;
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(connectionString))
{
return ToolResult.Fail("Konfigurationsfehler: 'type' und 'connectionString' sind erforderlich.");
}
try
{
if (type == "mongodb")
{
return await HandleMongoAsync(action, input, connectionString, context, ct);
}
else
{
return await HandleSqlAsync(type, action, input, connectionString, context, ct);
}
}
catch (Exception ex)
{
context.Logger.LogError(ex, "Fehler bei Database Aktion {Action} ({Type})", action, type);
return ToolResult.Fail($"Fehler: {ex.Message}");
}
}
private async Task<ToolResult> HandleSqlAsync(string type, string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
{
var sql = input.TryGetProperty("sql", out var s) ? s.GetString() : null;
if (string.IsNullOrWhiteSpace(sql)) return ToolResult.Fail("'sql' ist erforderlich.");
var accessLevel = GetAccessLevel(context);
// Statement analysieren: Kommentare, Mehrfach-Statements und String-Literale
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
if (Verweigerung(inspection, accessLevel, context.FreigabeGeprueft) is { } verweigert)
return ToolResult.Fail(verweigert);
using DbConnection conn = type switch
{
"mysql" => new MySqlConnection(connectionString),
"postgres" => new NpgsqlConnection(connectionString),
"mssql" => new SqlConnection(connectionString),
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
};
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
// #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>>();
while (await reader.ReadAsync(ct))
{
var row = new Dictionary<string, object>();
for (int i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.GetValue(i);
}
results.Add(row);
}
return ToolResult.Ok(JsonSerializer.Serialize(results, new JsonSerializerOptions { WriteIndented = true }));
}
else
{
var affected = await cmd.ExecuteNonQueryAsync(ct);
return ToolResult.Ok(inspection.Operation == SqlOperation.Schema
? $"Strukturänderung ausgeführt ({affected} Zeilen betroffen)."
: $"{affected} Zeilen betroffen.");
}
}
private async Task<ToolResult> HandleMongoAsync(string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
{
var collectionName = input.TryGetProperty("collection", out var c) ? c.GetString() : null;
if (string.IsNullOrWhiteSpace(collectionName)) return ToolResult.Fail("'collection' ist erforderlich.");
if (!IsCollectionAllowed(collectionName, context))
{
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Collection '{collectionName}' ist nicht erlaubt.");
}
var accessLevel = GetAccessLevel(context);
var client = new MongoClient(connectionString);
var dbName = new MongoUrl(connectionString).DatabaseName;
var db = client.GetDatabase(dbName);
var collection = db.GetCollection<BsonDocument>(collectionName);
if (action == "query")
{
var filterJson = input.TryGetProperty("filter", out var f) ? f.GetString() : "{}";
var filter = BsonDocument.Parse(filterJson);
var docs = await collection.Find(filter).Limit(100).ToListAsync(ct);
var results = docs.Select(d => d.ToJson()).ToList();
return ToolResult.Ok("[" + string.Join(",", results) + "]");
}
else if (action == "insert")
{
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);
return ToolResult.Ok("Dokument erfolgreich eingefügt.");
}
else // upsert
{
if (accessLevel == DatabaseAccessLevel.ReadOnly && !context.FreigabeGeprueft) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
return ToolResult.Fail("Upsert für MongoDB noch nicht voll implementiert.");
}
}
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)
{
if (Enum.TryParse<DatabaseAccessLevel>(val.ToString(), true, out var level))
return level;
}
// Rückfall auf altes 'allowWrite' für Abwärtskompatibilität
if (context.ToolConfig.TryGetValue("allowWrite", out var aw) && aw is JsonElement je && je.GetBoolean())
{
return DatabaseAccessLevel.ReadWrite;
}
return DatabaseAccessLevel.ReadOnly;
}
internal static List<string> GetAllowedTables(AgentToolContext context)
{
if (!context.ToolConfig.TryGetValue("allowedTables", out var val) || val is not JsonElement je
|| je.ValueKind != JsonValueKind.Array)
{
return [];
}
return je.EnumerateArray()
.Select(x => x.GetString())
.Where(s => !string.IsNullOrWhiteSpace(s))
.ToList()!;
}
/// <summary>
/// MongoDB kennt kein SQL — hier genügt der exakte Vergleich des Collection-Namens.
/// Bisher wurde auch hier per Teilzeichenkette geprüft.
/// </summary>
private static bool IsCollectionAllowed(string collection, AgentToolContext context)
=> GetAllowedTables(context).Contains(collection, StringComparer.OrdinalIgnoreCase);
}