Files
ClawdDotNet/src/ClawdDotNet.Tools.Database/DatabaseTool.cs
T
RichardandClaude Opus 5.5 a02b1996dd Schemaauskunft im Datenbank-Werkzeug: "kein Feld" wird eine Feststellung (#676)
- Aktionen describe (alle Spalten einer freigegebenen Tabelle, mit dem Satz, dass nicht
  Genanntes nicht existiert) und tables (freigegebene Tabellen, vorhanden oder fehlend)
- "Unknown column" / "no such table" usw. werden mit dem Vorhandenen beantwortet -
  MySQL, Postgres, SQL Server, SQLite; nur fuer freigegebene Tabellen
- SQLite als Datenbanktyp; die Abfragen schreibt das Werkzeug selbst, PRAGMA bleibt im
  Agenten-SQL verboten
- MongoDB: describe nennt die Felder aus den Dokumenten
- describe/tables sind Lesezugriffe (Freigabeschluessel select), Vorpruefung der Tabelle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 10:13:56 +02:00

454 lines
20 KiB
C#

using System.Data;
using System.Data.Common;
using System.Text.Json;
using ClawdDotNet.Core.Tools;
using Microsoft.Data.SqlClient;
using Microsoft.Data.Sqlite;
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";
/// <summary>#715: Brings text from outside into the context — rows that often come from outside (orders, reviews, feeds).</summary>
public bool ReadsForeignContent => true;
public string Description => """
Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL, SQLite) or MongoDB.
Before you use a column you have not seen: action "describe" with "table" lists every column of
the table - do not guess names. Action "tables" lists the tables you may use and whether they exist.
What "describe" does not list does not exist - say so instead of guessing.
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.
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; describe (collection)
lists the fields found in its documents.
""";
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["query", "insert", "upsert", "describe", "tables"],
"description": "The action to perform. describe: the columns of a table; tables: the tables you may use."
},
"table": {
"type": "string",
"description": "describe: the table (SQL) whose columns to list"
},
"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();
/// <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
// #676: The schema questions are reads of allowed tables — the table is checked, no SQL is sent.
var action = GetString(input, "action");
if (action == "tables")
return null;
if (action == "describe" && type != "mongodb")
{
var table = GetString(input, "table");
if (string.IsNullOrWhiteSpace(table))
return "'table' is required for describe.";
return IsTableAllowed(table, context) ? null : $"Sicherheitsfehler: Zugriff auf Tabelle '{table}' ist nicht erlaubt.";
}
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" or "describe" or "tables" => "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);
}
if (action is "describe" or "tables")
{
return await HandleSchemaAsync(type, 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 var conn = Connect(type, connectionString);
await conn.OpenAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
try
{
return await RunAsync(cmd, inspection, ct);
}
catch (DbException ex) when (SchemaLookup.Classify(ex.Message).Miss != SchemaMiss.None)
{
// #676: A refusal that knows the next step names it — the columns or tables that exist.
return ToolResult.Fail($"Fehler: {ex.Message}\n{await HintAsync(conn, type, ex.Message, inspection, context, ct)}");
}
}
private static async Task<ToolResult> RunAsync(DbCommand cmd, SqlInspection inspection, CancellationToken ct)
{
// #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 static DbConnection Connect(string type, string connectionString) => type switch
{
"mysql" => new MySqlConnection(connectionString),
"postgres" => new NpgsqlConnection(connectionString),
"mssql" => new SqlConnection(connectionString),
"sqlite" => new SqliteConnection(connectionString),
_ => throw new NotSupportedException($"SQL Typ '{type}' wird nicht unterstützt.")
};
/// <summary>
/// #676: "describe" — every column of an allowed table; "tables" — the allowed tables and
/// whether they exist. Asked of the database, never guessed.
/// </summary>
private static async Task<ToolResult> HandleSchemaAsync(
string type, string action, JsonElement input, string connectionString, AgentToolContext context, CancellationToken ct)
{
var allowed = GetAllowedTables(context);
using var conn = Connect(type, connectionString);
await conn.OpenAsync(ct);
if (action == "tables")
{
if (allowed.Count == 0)
return ToolResult.Ok("No tables are released for you (allowedTables is empty).");
var (existing, missing) = SchemaLookup.Split(allowed, await SchemaLookup.TablesAsync(conn, type, ct));
return ToolResult.Ok(
(existing.Count == 0 ? "None of the tables released for you exists in the database." : $"Tables you may use: {string.Join(", ", existing)}.") +
(missing.Count == 0 ? "" : $"\nReleased for you but not in the database: {string.Join(", ", missing)}."));
}
var table = GetString(input, "table");
if (string.IsNullOrWhiteSpace(table))
return ToolResult.Fail("'table' is required for describe.");
if (!IsTableAllowed(table, context))
return ToolResult.Fail($"Sicherheitsfehler: Zugriff auf Tabelle '{table}' ist nicht erlaubt. Erlaubt sind: {string.Join(", ", allowed)}.");
var columns = await SchemaLookup.ColumnsAsync(conn, type, table, ct);
if (columns.Count == 0)
{
var (existing, _) = SchemaLookup.Split(allowed, await SchemaLookup.TablesAsync(conn, type, ct));
return ToolResult.Fail($"Table '{table}' does not exist in the database." +
(existing.Count == 0 ? "" : $" Tables you may use: {string.Join(", ", existing)}."));
}
return ToolResult.Ok(SchemaLookup.DescribeTable(SchemaLookup.StripSchema(table), columns));
}
/// <summary>
/// #676: After "unknown column" the columns of the tables in the statement; after "unknown
/// table" the allowed tables that exist. Only ever allowed tables — the guard let the
/// statement through, so every table in it is one. A hint that fails costs the hint, not the answer.
/// </summary>
private static async Task<string> HintAsync(
DbConnection conn, string type, string message, SqlInspection inspection, AgentToolContext context, CancellationToken ct)
{
try
{
var (miss, name) = SchemaLookup.Classify(message);
if (miss == SchemaMiss.UnknownColumn)
{
var lines = new List<string>();
foreach (var table in inspection.Tables.Select(SchemaLookup.StripSchema).Distinct(StringComparer.OrdinalIgnoreCase))
{
var columns = await SchemaLookup.ColumnsAsync(conn, type, table, ct);
if (columns.Count > 0)
lines.Add($"Columns of '{table}': {SchemaLookup.ColumnList(columns)}.");
}
return lines.Count == 0
? "Use action \"describe\" to see the columns."
: $"There is no column '{name}'. " + string.Join(" ", lines) + " Use one of these, or say that the field does not exist.";
}
var (existing, _) = SchemaLookup.Split(GetAllowedTables(context), await SchemaLookup.TablesAsync(conn, type, ct));
return $"There is no table '{SchemaLookup.StripSchema(name ?? "")}'. " +
(existing.Count == 0 ? "None of the tables released for you exists." : $"Tables you may use: {string.Join(", ", existing)}.");
}
catch (Exception)
{
return "Use action \"describe\" to see the columns of a table.";
}
}
private static bool IsTableAllowed(string table, AgentToolContext context)
=> GetAllowedTables(context).Contains(SchemaLookup.StripSchema(table), StringComparer.OrdinalIgnoreCase);
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 == "tables")
{
return ToolResult.Ok($"Collections you may use: {string.Join(", ", GetAllowedTables(context))}.");
}
if (action == "describe")
{
// #676: MongoDB has no schema — the fields found in the documents are the next best thing.
var sample = await collection.Find(new BsonDocument()).Limit(50).ToListAsync(ct);
var fields = sample.SelectMany(d => d.Names).Distinct().ToList();
return ToolResult.Ok(sample.Count == 0
? $"Collection '{collectionName}' has no documents."
: $"Fields found in {sample.Count} document(s) of '{collectionName}': {string.Join(", ", fields)}. " +
"A collection has no fixed schema — a field missing here may still exist in older documents.");
}
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);
}