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:
co-authored by
Claude Opus 5.5
parent
17d90cf952
commit
a02b1996dd
@@ -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() : "{}";
|
||||
|
||||
Reference in New Issue
Block a user