- 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>
454 lines
20 KiB
C#
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);
|
|
}
|