- IAgentTool.ReadsForeignContent: Werkzeuge mit Aussenkontakt erklaeren sich selbst (WebFetch, WebMonitor, DirectAPI, Mail, RocketChat, Telegram, TelegramClient, SocialMediaManager, FTP, Database); AgentConfig.TreatAsForeignReader kann nur verschaerfen - ReportingLine: Stufe aus den Werkzeugen, "wer steht darueber", Pruefung des Wegs nach oben (nie Aenderungen an Aufgaben darueber, nur eine Ebene hoch, Fremdinhalt-Stufe gar kein Freitext) und Rahmung als untrusted_content - Engine: Nachricht und Spawn nach oben geprueft, Text der Fremdinhalt-Stufe kommt gerahmt an - Taskboard: task_create/update/comment, reopen und die Dubletten-Notiz folgen dem Berichtsweg - Dispatcher: Aufgaben von Agenten der Fremdinhalt-Stufe gehen gerahmt in den Lauf - TaskReports: Erledigt-Signal und Systemmeldung (in Pruefung, blockiert, abgebrochen) an den Auftraggeber, je Zustand einmal, gesammelt in einer offenen Aufgabe je Empfaenger - OrgBriefing nennt den Berichtsweg; WebUI zeigt Stufe und Schalter am Agenten Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
325 lines
13 KiB
C#
325 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";
|
|
|
|
/// <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) or MongoDB.
|
|
|
|
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.
|
|
""";
|
|
|
|
public JsonElement InputSchema { get; } = JsonDocument.Parse("""
|
|
{
|
|
"type": "object",
|
|
"properties": {
|
|
"action": {
|
|
"type": "string",
|
|
"enum": ["query", "insert", "upsert"],
|
|
"description": "The action to perform"
|
|
},
|
|
"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
|
|
|
|
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);
|
|
}
|