#643: Entscheidungen wecken den Agenten nur noch bei Ablehnung oder gescheiterter Ausfuehrung, gesammelt in einer Aufgabe je Agent (5 min Ruhefenster), frisch per @new:agent statt im fortgefuehrten Chatverlauf. Eine einmalige Aufgabe, deren Lauf wegen Budget oder Herunterfahren gar nicht stattfand, bleibt faellig (Wiedervorlage nach 30 min) statt mit gesetztem Marker liegenzubleiben. #641: Rechte stehen in der Werkzeugkonfiguration des Agenten (erlaubt/verboten). Erlaubtes laeuft, Nicht-Erlaubtes wird einzeln vorgelegt statt verweigert, Verbotenes gar nicht erst. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare Dauerfreigabe je Agent/Werkzeug/Operation; nach drei gleichartigen Freigaben schlaegt die Oberflaeche sie vor. Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig. Database: Operation ist das SQL-Schluesselwort; die eigene Zugriffspruefung greift nur noch ohne Freigabestelle (AgentToolContext.FreigabeGeprueft). WebUI: Rechte-Tabelle je Werkzeug, Dauerfreigaben mit Widerruf. Version 0.1.11. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
13 KiB
C#
323 lines
13 KiB
C#
using System.Data;
|
|
using System.Data.Common;
|
|
using System.Text.Json;
|
|
using ClawdDotNet.Core.Tools;
|
|
using Microsoft.Data.SqlClient;
|
|
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";
|
|
|
|
public string Description => """
|
|
Zugriff auf eine SQL-Datenbank (MySQL/MariaDB, Postgres, MSSQL) oder MongoDB.
|
|
|
|
SQL: Schicke jedes Statement mit action "query" und "sql" - lesend wie schreibend. Die Art
|
|
(lesen, schreiben, Struktur aendern) ergibt sich aus dem Statement selbst. Ein Statement je
|
|
Aufruf, keine SQL-Kommentare. Nur die freigegebenen Tabellen sind erreichbar.
|
|
Tabellenstruktur: "DESCRIBE <tabelle>" oder "SHOW COLUMNS FROM <tabelle>" - pruefe die
|
|
Spaltennamen, bevor du schreibst. Schreibende Statements liefern die Zahl betroffener Zeilen.
|
|
Welche Operationen (SELECT, INSERT, UPDATE, ALTER ...) du ohne Rueckfrage ausfuehren darfst,
|
|
legt deine Werkzeugkonfiguration fest. Andere werden einzeln zur Freigabe vorgelegt statt
|
|
sofort ausgefuehrt; liegt derselbe Vorschlag schon vor, lege ihn nicht erneut vor.
|
|
|
|
MongoDB: action query (filter), insert (document) in einer freigegebenen collection.
|
|
""";
|
|
|
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["query", "insert", "upsert"],
|
|
"description": "Die auszuführende Aktion"
|
|
},
|
|
"sql": {
|
|
"type": "string",
|
|
"description": "Das auszuführende SQL Statement (nur für SQL-Typen)"
|
|
},
|
|
"collection": {
|
|
"type": "string",
|
|
"description": "Der Name der Collection (nur für MongoDB)"
|
|
},
|
|
"filter": {
|
|
"type": "string",
|
|
"description": "JSON-Filter (nur für MongoDB)"
|
|
},
|
|
"document": {
|
|
"type": "string",
|
|
"description": "JSON-Dokument zum Einfügen/Update (nur für MongoDB)"
|
|
}
|
|
},
|
|
"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
|
|
|
|
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" => "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);
|
|
}
|
|
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 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.")
|
|
};
|
|
|
|
await conn.OpenAsync(ct);
|
|
using var cmd = conn.CreateCommand();
|
|
cmd.CommandText = sql;
|
|
|
|
// #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 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 == "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);
|
|
}
|