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>
This commit is contained in:
Richard
2026-09-25 10:13:56 +02:00
co-authored by Claude Opus 5.5
parent 17d90cf952
commit a02b1996dd
5 changed files with 461 additions and 20 deletions
+143 -14
View File
@@ -3,6 +3,7 @@ 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;
@@ -26,17 +27,21 @@ public sealed class DatabaseTool : IAgentTool
public bool ReadsForeignContent => true;
public string Description => """
Access to a SQL database (MySQL/MariaDB, Postgres, MSSQL) or MongoDB.
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.
Table schema: "DESCRIBE <table>" or "SHOW COLUMNS FROM <table>" - 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.
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("""
@@ -45,8 +50,12 @@ public sealed class DatabaseTool : IAgentTool
"properties": {
"action": {
"type": "string",
"enum": ["query", "insert", "upsert"],
"description": "The action to perform"
"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",
@@ -83,6 +92,18 @@ public sealed class DatabaseTool : IAgentTool
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");
@@ -115,7 +136,7 @@ public sealed class DatabaseTool : IAgentTool
return GetString(input, "action") switch
{
"query" => "select",
"query" or "describe" or "tables" => "select",
"insert" => "insert",
"upsert" => "update",
_ => null
@@ -173,6 +194,10 @@ public sealed class DatabaseTool : IAgentTool
{
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);
@@ -199,18 +224,24 @@ public sealed class DatabaseTool : IAgentTool
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.")
};
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)
@@ -237,6 +268,90 @@ public sealed class DatabaseTool : IAgentTool
}
}
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;
@@ -253,6 +368,20 @@ public sealed class DatabaseTool : IAgentTool
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() : "{}";