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() : "{}";
@@ -0,0 +1,151 @@
using System.Data.Common;
using System.Text;
using System.Text.RegularExpressions;
namespace ClawdDotNet.Tools.Database;
/// <summary>One column of a table, as the database describes it.</summary>
public sealed record ColumnInfo(string Name, string Type, bool Nullable);
/// <summary>What a failed statement was missing, read from the database's own message.</summary>
public enum SchemaMiss
{
None,
UnknownColumn,
UnknownTable
}
/// <summary>
/// #676: The schema, asked of the database — so that "there is no field for it" is a finding,
/// not a guess.
///
/// <para>Observed in the test operation (#572): agents guessed column names and got only the
/// refusal back ("Unknown column 'zeitpunkt'"). Each guess cost two or three steps, and the
/// workaround — column names written into the task text — does not scale. The organisation
/// concept goes further (section 5): without a reliable negative finding an escalation is an
/// escalation of a guess.</para>
///
/// <para>Only ever for tables the agent may use. The lookups are written here, with the table
/// name as a parameter — the agent's own SQL still passes the guard, where PRAGMA and friends
/// stay forbidden.</para>
/// </summary>
public static class SchemaLookup
{
public static async Task<IReadOnlyList<ColumnInfo>> ColumnsAsync(DbConnection conn, string type, string table, CancellationToken ct)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = type switch
{
"mysql" => "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS " +
"WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = @t ORDER BY ORDINAL_POSITION",
"postgres" => "SELECT column_name, data_type, is_nullable FROM information_schema.columns " +
"WHERE table_schema = ANY (current_schemas(false)) AND table_name = @t ORDER BY ordinal_position",
"mssql" => "SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE FROM INFORMATION_SCHEMA.COLUMNS " +
"WHERE TABLE_NAME = @t ORDER BY ORDINAL_POSITION",
"sqlite" => "SELECT name, type, CASE WHEN \"notnull\" = 1 THEN 'NO' ELSE 'YES' END FROM pragma_table_info(@t)",
_ => throw new NotSupportedException($"SQL type '{type}' is not supported.")
};
var parameter = cmd.CreateParameter();
parameter.ParameterName = "@t";
parameter.Value = StripSchema(table);
cmd.Parameters.Add(parameter);
var columns = new List<ColumnInfo>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
{
columns.Add(new ColumnInfo(
reader.GetString(0),
reader.IsDBNull(1) ? "" : reader.GetString(1),
!reader.IsDBNull(2) && string.Equals(reader.GetString(2), "YES", StringComparison.OrdinalIgnoreCase)));
}
return columns;
}
/// <summary>The tables (and views) the database has, in the current database or schema.</summary>
public static async Task<IReadOnlyList<string>> TablesAsync(DbConnection conn, string type, CancellationToken ct)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = type switch
{
"mysql" => "SELECT TABLE_NAME FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE()",
"postgres" => "SELECT table_name FROM information_schema.tables WHERE table_schema = ANY (current_schemas(false))",
"mssql" => "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES",
"sqlite" => "SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'",
_ => throw new NotSupportedException($"SQL type '{type}' is not supported.")
};
var tables = new List<string>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
tables.Add(reader.GetString(0));
return tables;
}
// ═══════════════════ reading the database's refusal ═══════════════════
private static readonly Regex[] UnknownColumnPatterns =
[
new(@"Unknown column '(?<n>[^']+)'", RegexOptions.IgnoreCase), // MySQL / MariaDB
new(@"column ""(?<n>[^""]+)""(?: of relation ""[^""]+"")? does not exist", RegexOptions.IgnoreCase), // Postgres
new(@"Invalid column name '(?<n>[^']+)'", RegexOptions.IgnoreCase), // SQL Server
new(@"no such column: (?<n>[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase), // SQLite
new(@"has no column named (?<n>[A-Za-z0-9_$]+)", RegexOptions.IgnoreCase) // SQLite, INSERT
];
private static readonly Regex[] UnknownTablePatterns =
[
new(@"Table '(?<n>[^']+)' doesn't exist", RegexOptions.IgnoreCase), // MySQL / MariaDB
new(@"relation ""(?<n>[^""]+)"" does not exist", RegexOptions.IgnoreCase), // Postgres
new(@"Invalid object name '(?<n>[^']+)'", RegexOptions.IgnoreCase), // SQL Server
new(@"no such table: (?<n>[A-Za-z0-9_.$]+)", RegexOptions.IgnoreCase) // SQLite
];
/// <summary>What the message says was missing, and its name as the database wrote it.</summary>
public static (SchemaMiss Miss, string? Name) Classify(string? message)
{
if (string.IsNullOrWhiteSpace(message))
return (SchemaMiss.None, null);
foreach (var pattern in UnknownColumnPatterns)
if (pattern.Match(message) is { Success: true } m)
return (SchemaMiss.UnknownColumn, m.Groups["n"].Value);
foreach (var pattern in UnknownTablePatterns)
if (pattern.Match(message) is { Success: true } m)
return (SchemaMiss.UnknownTable, m.Groups["n"].Value);
return (SchemaMiss.None, null);
}
// ═══════════════════ saying it ═══════════════════
/// <summary>"Columns of 'messwerte' (5): gemessen_um datetime, ..." — and that these are all.</summary>
public static string DescribeTable(string table, IReadOnlyList<ColumnInfo> columns)
{
if (columns.Count == 0)
return $"Table '{table}' does not exist in the database (or has no columns).";
var sb = new StringBuilder($"Columns of '{table}' ({columns.Count}):");
foreach (var column in columns)
sb.Append($"\n- {column.Name} {column.Type}{(column.Nullable ? "" : " not null")}");
sb.Append($"\nThese are all the columns '{table}' has. A field that is not listed does not exist.");
return sb.ToString();
}
public static string ColumnList(IReadOnlyList<ColumnInfo> columns)
=> string.Join(", ", columns.Select(c => c.Name));
/// <summary>The allowed tables, split into those that exist and those that do not.</summary>
public static (IReadOnlyList<string> Existing, IReadOnlyList<string> Missing) Split(
IReadOnlyCollection<string> allowed, IReadOnlyCollection<string> inDatabase)
{
var present = new HashSet<string>(inDatabase, StringComparer.OrdinalIgnoreCase);
return (allowed.Where(present.Contains).ToList(), allowed.Where(t => !present.Contains(t)).ToList());
}
/// <summary>"schema.table" → "table", as the whitelist lists names.</summary>
public static string StripSchema(string table)
{
var name = table.Trim().Trim('`', '"', '[', ']');
var dot = name.LastIndexOf('.');
return dot >= 0 ? name[(dot + 1)..].Trim('`', '"', '[', ']') : name;
}
}