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"; /// #715: Brings text from outside into the context — rows that often come from outside (orders, reviews, feeds). public bool ReadsForeignContent => true; public string Description => """ Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL) or MongoDB. SQL: Send each statement with action "query" and "sql" - both read and write. The operation type (read, write, schema change) is determined from the statement itself. One statement per call, no SQL comments. Only configured allowed tables can be accessed. Table schema: "DESCRIBE " or "SHOW COLUMNS FROM
" - verify column names before writing. Write statements return the number of affected rows. Which operations (SELECT, INSERT, UPDATE, ALTER...) you can execute without confirmation is determined by your tool configuration. Other operations will require approval. MongoDB: action query (filter), insert (document) in an allowed collection. """; public JsonElement InputSchema { get; } = JsonDocument.Parse(""" { "type": "object", "properties": { "action": { "type": "string", "enum": ["query", "insert", "upsert"], "description": "The action to perform" }, "sql": { "type": "string", "description": "The SQL statement to execute (SQL database types only)" }, "collection": { "type": "string", "description": "The collection name (MongoDB only)" }, "filter": { "type": "string", "description": "JSON filter (MongoDB only)" }, "document": { "type": "string", "description": "JSON document to insert/update (MongoDB only)" } }, "required": ["action"] } """).RootElement.Clone(); /// /// #579: Was das Werkzeug ohnehin ablehnen wuerde, kommt nicht in die Freigabeliste - /// unlesbares SQL, nicht freigegebene Tabellen und Collections. /// /// #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. /// 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}"; } /// Die Operationen, die sich je Agent erlauben lassen (#641). public IReadOnlyList Operationen { get; } = ["select", "insert", "update", "delete", "replace", "create", "alter", "drop", "truncate"]; /// /// #609/#641: Der Freigabeschluessel folgt dem Statement, nicht dem action-Argument - ein /// INSERT ist "insert", auch wenn das Modell action "query" schickt. /// 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 }; } /// /// Ohne erlaubt in der Konfiguration: Lesen laeuft, alles andere wird vorgelegt. /// public Core.Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary config) => operation == "select" ? Core.Staging.StagingDecision.Auto : Core.Staging.StagingDecision.Approve; /// /// Die eigene Rechtepruefung des Werkzeugs. Laeuft nur, wenn keine Freigabestelle den /// Aufruf geprueft hat () - dann gilt die /// Zugriffsstufe als harte Grenze. /// 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 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 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>(); while (await reader.ReadAsync(ct)) { var row = new Dictionary(); 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 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(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(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 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()!; } /// /// MongoDB kennt kein SQL — hier genügt der exakte Vergleich des Collection-Namens. /// Bisher wurde auch hier per Teilzeichenkette geprüft. /// private static bool IsCollectionAllowed(string collection, AgentToolContext context) => GetAllowedTables(context).Contains(collection, StringComparer.OrdinalIgnoreCase); }