#631: Derselbe Vorschlag (gleicher Agent, gleiches Werkzeug, gleiche Argumente unabhaengig von Schluesselreihenfolge) wird nicht erneut angelegt - der Agent erfaehrt die bestehende Nummer. Offene Vorschlaege verfallen nach 24 h (Status Expired). #609: Werkzeuge koennen den Freigabeschluessel aus dem Inhalt bestimmen (IAgentTool.StagingAktion). Database: SELECT/SHOW/DESCRIBE -> query, INSERT/UPDATE/DELETE -> write, DDL -> schema; write und schema sind freigabepflichtig. Ausgefuehrt wird nach Statement-Art, schreibende Statements melden die Zeilenzahl. #579: Database-Vorpruefung (Zugriffsebene, Tabellenfreigabe, SqlGuard) - was das Werkzeug ablehnen wuerde, kommt nicht mehr in die Freigabeliste. #610: SqlGuard ueberspringt IF [NOT] EXISTS und erkennt DESCRIBE/DESC <tabelle>. #632: WebFetch fetch mit filter liefert aus JSON/JSONP nur passende Eintraege; beim Blaettern durch grosses JSON empfiehlt der Hinweis den filter. Version 0.1.10. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -18,7 +18,7 @@
|
||||
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
|
||||
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe.
|
||||
-->
|
||||
<Version>0.1.9</Version>
|
||||
<Version>0.1.10</Version>
|
||||
|
||||
<!--
|
||||
Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
|
||||
|
||||
@@ -1393,7 +1393,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
|
||||
if (_stagingGate is not null)
|
||||
{
|
||||
var intercept = await _stagingGate.InterceptAsync(
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct);
|
||||
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
|
||||
tool.StagingAktion(input));
|
||||
|
||||
if (intercept.Outcome == Staging.StagingOutcome.Denied)
|
||||
{
|
||||
|
||||
@@ -106,6 +106,38 @@ public sealed class SqliteStagingRepository : IStagingRepository
|
||||
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<StagedCall>> ListPendingForAgentAsync(string agentId, string tool, CancellationToken ct)
|
||||
{
|
||||
await using var conn = await _storage.OpenConnectionAsync(ct);
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "SELECT " + Columns + " FROM StagedCalls " +
|
||||
"WHERE Status = 'Pending' AND AgentId = @agentId AND Tool = @tool ORDER BY Id";
|
||||
cmd.Parameters.AddWithValue("@agentId", agentId);
|
||||
cmd.Parameters.AddWithValue("@tool", tool);
|
||||
|
||||
var results = new List<StagedCall>();
|
||||
await using var reader = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await reader.ReadAsync(ct))
|
||||
results.Add(Read(reader));
|
||||
return results;
|
||||
}
|
||||
|
||||
public Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct)
|
||||
=> _storage.WriteAsync(async conn =>
|
||||
{
|
||||
// CreatedAt steht als ISO-8601 in UTC ("O") - der Textvergleich ist ein Zeitvergleich.
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = """
|
||||
UPDATE StagedCalls
|
||||
SET Status = 'Expired', DecidedAt = @now, DecidedBy = 'system',
|
||||
RejectionReason = 'Verfallen: zu lange unentschieden'
|
||||
WHERE Status = 'Pending' AND CreatedAt < @before
|
||||
""";
|
||||
cmd.Parameters.AddWithValue("@now", Format(now));
|
||||
cmd.Parameters.AddWithValue("@before", Format(createdBefore));
|
||||
return await cmd.ExecuteNonQueryAsync(ct);
|
||||
}, ct);
|
||||
|
||||
// ─── Hilfsfunktionen ───
|
||||
|
||||
private const string Columns =
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ClawdDotNet.Core.Staging;
|
||||
|
||||
@@ -28,20 +29,42 @@ public readonly record struct StagingInterception(StagingOutcome Outcome, string
|
||||
/// </summary>
|
||||
public sealed class StagingGate
|
||||
{
|
||||
/// <summary>
|
||||
/// Wie lange ein Vorschlag offen bleiben darf (#631). Im Testbetrieb standen nach knapp drei
|
||||
/// Tagen ueber hundert Vorschlaege an; freigegeben wurde am Ende, was laengst ueberholt war.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan StandardVerfall = TimeSpan.FromHours(24);
|
||||
|
||||
private readonly StagingPolicy _policy;
|
||||
private readonly IStagingRepository _repo;
|
||||
private readonly TimeSpan _verfall;
|
||||
private readonly TimeProvider _clock;
|
||||
|
||||
public StagingGate(StagingPolicy policy, IStagingRepository repo)
|
||||
public StagingGate(StagingPolicy policy, IStagingRepository repo, TimeSpan? verfall = null, TimeProvider? clock = null)
|
||||
{
|
||||
_policy = policy;
|
||||
_repo = repo;
|
||||
_verfall = verfall ?? StandardVerfall;
|
||||
_clock = clock ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
/// <summary>Verwirft offene Vorschlaege, die aelter als die Verfallsfrist sind. Gibt die Anzahl zurueck.</summary>
|
||||
public Task<int> ExpireAsync(CancellationToken ct)
|
||||
{
|
||||
var jetzt = _clock.GetUtcNow().UtcDateTime;
|
||||
return _repo.ExpirePendingAsync(jetzt - _verfall, jetzt, ct);
|
||||
}
|
||||
|
||||
/// <param name="action">
|
||||
/// Der Aktionsschluessel, den das Werkzeug aus dem Inhalt des Aufrufs bestimmt hat
|
||||
/// (IAgentTool.StagingAktion). Ohne ihn gilt das <c>action</c>-Argument - das aber waehlt
|
||||
/// das Modell frei (#609).
|
||||
/// </param>
|
||||
public async Task<StagingInterception> InterceptAsync(
|
||||
string agentId, string instanceId, string runId,
|
||||
string tool, string argumentsJson, CancellationToken ct)
|
||||
string tool, string argumentsJson, CancellationToken ct, string? action = null)
|
||||
{
|
||||
var action = ExtractAction(argumentsJson);
|
||||
action ??= ExtractAction(argumentsJson);
|
||||
|
||||
switch (_policy.Decide(tool, action))
|
||||
{
|
||||
@@ -55,6 +78,22 @@ public sealed class StagingGate
|
||||
0);
|
||||
|
||||
case StagingDecision.Approve:
|
||||
await ExpireAsync(ct);
|
||||
|
||||
// #631: Derselbe Vorschlag liegt schon vor - kein zweiter Eintrag. Vorher legte
|
||||
// ein Agent dieselbe Loeschung jede Nacht erneut vor, bis 28 Dateien mehrfach
|
||||
// in der Liste standen.
|
||||
if (await FindeOffenenAsync(agentId, tool, argumentsJson, ct) is { } vorhanden)
|
||||
{
|
||||
return new StagingInterception(
|
||||
StagingOutcome.Staged,
|
||||
$"Dieser Vorschlag liegt bereits als #{vorhanden.Id} zur Freigabe vor " +
|
||||
$"(seit {vorhanden.CreatedAt:yyyy-MM-dd HH:mm} UTC) und wurde nicht erneut angelegt. " +
|
||||
"Lege ihn nicht noch einmal vor; du wirst geweckt, sobald entschieden ist. " +
|
||||
"Fahre mit anderer Arbeit fort oder schließe ab.",
|
||||
vorhanden.Id);
|
||||
}
|
||||
|
||||
var id = await _repo.AppendAsync(new StagedCall
|
||||
{
|
||||
RunId = runId,
|
||||
@@ -78,6 +117,45 @@ public sealed class StagingGate
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<StagedCall?> FindeOffenenAsync(string agentId, string tool, string argumentsJson, CancellationToken ct)
|
||||
{
|
||||
var gesucht = Kanonisch(argumentsJson);
|
||||
foreach (var offen in await _repo.ListPendingForAgentAsync(agentId, tool, ct))
|
||||
{
|
||||
if (Kanonisch(offen.ArgumentsJson) == gesucht)
|
||||
return offen;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Argumente in fester Form: Schluessel sortiert, ohne Leerraum. Das Modell schreibt dieselben
|
||||
/// Argumente mal als {"action","workspace","path"}, mal als {"action","path","workspace"}.
|
||||
/// </summary>
|
||||
internal static string Kanonisch(string argumentsJson)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonNode.Parse(argumentsJson) is { } knoten
|
||||
? Sortiert(knoten)?.ToJsonString() ?? ""
|
||||
: "";
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return argumentsJson.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
private static JsonNode? Sortiert(JsonNode? knoten) => knoten switch
|
||||
{
|
||||
JsonObject obj => new JsonObject(
|
||||
obj.OrderBy(p => p.Key, StringComparer.Ordinal)
|
||||
.Select(p => KeyValuePair.Create(p.Key, Sortiert(p.Value)))),
|
||||
JsonArray arr => new JsonArray(arr.Select(Sortiert).ToArray()),
|
||||
null => null,
|
||||
_ => JsonNode.Parse(knoten.ToJsonString())
|
||||
};
|
||||
|
||||
/// <summary>Liest das <c>action</c>-Argument, wenn vorhanden — der Aktionsschlüssel der Policy.</summary>
|
||||
public static string? ExtractAction(string argumentsJson)
|
||||
{
|
||||
|
||||
@@ -29,7 +29,13 @@ public enum StagingStatus
|
||||
Failed,
|
||||
|
||||
/// <summary>Abgelehnt.</summary>
|
||||
Rejected
|
||||
Rejected,
|
||||
|
||||
/// <summary>
|
||||
/// Zu lange unentschieden und deshalb verworfen (#631). Eine Freigabe nach Tagen fuehrte
|
||||
/// sonst aus, was laengst ueberholt ist - doppelte Messwerte, laengst geloeschte Dateien.
|
||||
/// </summary>
|
||||
Expired
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -92,4 +98,13 @@ public interface IStagingRepository
|
||||
Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct);
|
||||
|
||||
Task<int> CountPendingAsync(CancellationToken ct);
|
||||
|
||||
/// <summary>Die offenen Vorschlaege eines Agenten fuer ein Werkzeug - zum Erkennen von Dubletten (#631).</summary>
|
||||
Task<IReadOnlyList<StagedCall>> ListPendingForAgentAsync(string agentId, string tool, CancellationToken ct);
|
||||
|
||||
/// <summary>
|
||||
/// Setzt alle offenen Vorschlaege, die vor <paramref name="createdBefore"/> angelegt wurden,
|
||||
/// auf <see cref="StagingStatus.Expired"/>. Gibt die Anzahl zurueck.
|
||||
/// </summary>
|
||||
Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,12 @@ public sealed class StagingPolicy
|
||||
["Telegram.send_message"] = StagingDecision.Approve,
|
||||
["Database.insert"] = StagingDecision.Approve,
|
||||
["Database.upsert"] = StagingDecision.Approve,
|
||||
|
||||
// #609: Bei SQL bestimmt das Werkzeug den Schluessel aus dem Statement selbst
|
||||
// (IAgentTool.StagingAktion) - "query" gilt nur noch fuer lesende Statements.
|
||||
// Vorher lief dasselbe INSERT ueber action "query" an der Freigabe vorbei.
|
||||
["Database.write"] = StagingDecision.Approve,
|
||||
["Database.schema"] = StagingDecision.Approve,
|
||||
["FileRW.delete"] = StagingDecision.Approve,
|
||||
["FTP.upload"] = StagingDecision.Approve,
|
||||
["FTP.delete"] = StagingDecision.Approve,
|
||||
|
||||
@@ -43,7 +43,13 @@ public sealed class StagingService
|
||||
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct) => _repo.ListPendingAsync(ct);
|
||||
public async Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
|
||||
{
|
||||
// #631: Was zu lange unentschieden liegt, kommt gar nicht erst wieder in die Ansicht.
|
||||
var jetzt = DateTime.UtcNow;
|
||||
await _repo.ExpirePendingAsync(jetzt - StagingGate.StandardVerfall, jetzt, ct);
|
||||
return await _repo.ListPendingAsync(ct);
|
||||
}
|
||||
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
|
||||
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ public interface IAgentTool
|
||||
/// </summary>
|
||||
string? Preflight(JsonElement input, AgentToolContext context) => null;
|
||||
|
||||
/// <summary>
|
||||
/// Der Aktionsschluessel fuer die Freigaberegel, bestimmt aus dem Inhalt des Aufrufs statt
|
||||
/// aus dem frei waehlbaren <c>action</c>-Argument (#609). Beispiel Database: ein INSERT ist
|
||||
/// "write", auch wenn das Modell action "query" schickt. <c>null</c> = das
|
||||
/// <c>action</c>-Argument gilt.
|
||||
/// </summary>
|
||||
string? StagingAktion(JsonElement input) => null;
|
||||
|
||||
Task<ToolResult> ExecuteAsync(
|
||||
JsonElement input,
|
||||
AgentToolContext context,
|
||||
|
||||
@@ -22,7 +22,19 @@ public sealed class DatabaseTool : IAgentTool
|
||||
{
|
||||
public string Name => "Database";
|
||||
|
||||
public string Description => "Erlaubt den Zugriff auf SQL (MySQL, Postgres, MSSQL) und NoSQL (MongoDB) Datenbanken mit verschiedenen Zugriffsebenen.";
|
||||
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.
|
||||
Je nach Einstellung werden schreibende oder strukturaendernde Statements 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("""
|
||||
{
|
||||
@@ -54,6 +66,73 @@ public sealed class DatabaseTool : IAgentTool
|
||||
}
|
||||
""").RootElement.Clone();
|
||||
|
||||
/// <summary>
|
||||
/// #579: Was das Werkzeug ohnehin ablehnen wuerde, kommt nicht in die Freigabeliste. Im
|
||||
/// Testbetrieb legte ein Agent mit ReadOnly seine Test-INSERTs zur Freigabe vor - nach der
|
||||
/// Freigabe scheiterten sie an genau dieser Pruefung.
|
||||
/// </summary>
|
||||
public string? Preflight(JsonElement input, AgentToolContext context)
|
||||
{
|
||||
var type = GetType(context);
|
||||
if (string.IsNullOrWhiteSpace(type))
|
||||
return null; // Konfigurationsfehler meldet die Ausfuehrung selbst
|
||||
|
||||
var accessLevel = GetAccessLevel(context);
|
||||
|
||||
if (type == "mongodb")
|
||||
{
|
||||
var action = GetString(input, "action");
|
||||
var collection = GetString(input, "collection");
|
||||
if (!string.IsNullOrWhiteSpace(collection) && !IsCollectionAllowed(collection, context))
|
||||
return $"Sicherheitsfehler: Zugriff auf Collection '{collection}' ist nicht erlaubt.";
|
||||
if (action is "insert" or "upsert" && accessLevel == DatabaseAccessLevel.ReadOnly)
|
||||
return "Sicherheitsfehler: Schreibzugriff deaktiviert.";
|
||||
return null;
|
||||
}
|
||||
|
||||
var sql = GetString(input, "sql");
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return "'sql' ist erforderlich.";
|
||||
|
||||
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
|
||||
return Verweigerung(inspection, accessLevel);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// #609: Der Freigabeschluessel folgt dem Statement, nicht dem action-Argument. Vorher lief
|
||||
/// ein INSERT, das als action "insert" zur Freigabe lag, eine Sekunde spaeter als action
|
||||
/// "query" ohne Freigabe durch.
|
||||
/// </summary>
|
||||
public string? StagingAktion(JsonElement input)
|
||||
{
|
||||
var sql = GetString(input, "sql");
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return null; // MongoDB: dort gilt das action-Argument
|
||||
|
||||
return SqlGuard.Inspect(sql, ["*"]).Operation switch
|
||||
{
|
||||
SqlOperation.Read => "query",
|
||||
SqlOperation.Write => "write",
|
||||
SqlOperation.Schema => "schema",
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Verweigerung(SqlInspection inspection, DatabaseAccessLevel accessLevel)
|
||||
{
|
||||
if (!inspection.IsValid)
|
||||
return $"Sicherheitsfehler: {inspection.Error}";
|
||||
|
||||
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,
|
||||
@@ -62,7 +141,7 @@ public sealed class DatabaseTool : IAgentTool
|
||||
var action = input.GetProperty("action").GetString()
|
||||
?? throw new ArgumentException("'action' is required");
|
||||
|
||||
var type = context.ToolConfig.TryGetValue("type", out var t) ? t?.ToString()?.ToLowerInvariant() : null;
|
||||
var type = GetType(context);
|
||||
var connectionString = context.ToolConfig.TryGetValue("connectionString", out var cs) ? cs?.ToString() : null;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(connectionString))
|
||||
@@ -99,19 +178,8 @@ public sealed class DatabaseTool : IAgentTool
|
||||
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
|
||||
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
|
||||
|
||||
if (!inspection.IsValid)
|
||||
return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}");
|
||||
|
||||
switch (inspection.Operation)
|
||||
{
|
||||
case SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin:
|
||||
return ToolResult.Fail(
|
||||
"Sicherheitsfehler: Strukturänderungen (DDL) sind für diesen Agenten nicht erlaubt.");
|
||||
|
||||
case SqlOperation.Write when accessLevel == DatabaseAccessLevel.ReadOnly:
|
||||
return ToolResult.Fail(
|
||||
"Sicherheitsfehler: Schreibzugriff ist für diesen Agenten deaktiviert (ReadOnly).");
|
||||
}
|
||||
if (Verweigerung(inspection, accessLevel) is { } verweigert)
|
||||
return ToolResult.Fail(verweigert);
|
||||
|
||||
using DbConnection conn = type switch
|
||||
{
|
||||
@@ -125,7 +193,9 @@ public sealed class DatabaseTool : IAgentTool
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
if (action == "query")
|
||||
// #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>>();
|
||||
@@ -143,7 +213,9 @@ public sealed class DatabaseTool : IAgentTool
|
||||
else
|
||||
{
|
||||
var affected = await cmd.ExecuteNonQueryAsync(ct);
|
||||
return ToolResult.Ok($"{affected} Zeilen betroffen.");
|
||||
return ToolResult.Ok(inspection.Operation == SqlOperation.Schema
|
||||
? $"Strukturänderung ausgeführt ({affected} Zeilen betroffen)."
|
||||
: $"{affected} Zeilen betroffen.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +258,15 @@ public sealed class DatabaseTool : IAgentTool
|
||||
}
|
||||
}
|
||||
|
||||
private DatabaseAccessLevel GetAccessLevel(AgentToolContext context)
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -64,6 +64,19 @@ public static class SqlGuard
|
||||
@"ADD|DROP|RENAME|MODIFY|ALTER)\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// #610: "CREATE TABLE IF NOT EXISTS t" / "DROP TABLE IF EXISTS t" - ohne das galt "IF" als
|
||||
/// Tabellenname und das uebliche, wiederholbare Anlegen wurde abgelehnt.
|
||||
/// </summary>
|
||||
private static readonly Regex IfExists = new(
|
||||
@"^\s*IF\s+(?:NOT\s+)?EXISTS\b",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>#610: "DESCRIBE t" / "DESC t" nennen die Tabelle ohne Schluesselwort davor.</summary>
|
||||
private static readonly Regex Describe = new(
|
||||
@"^\s*(?:DESCRIBE|DESC)\s+(?<rest>.*)$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled | RegexOptions.Singleline);
|
||||
|
||||
/// <summary>Erster Bezeichner eines Listeneintrags — ein evtl. folgender Alias wird ignoriert.</summary>
|
||||
private static readonly Regex LeadingIdentifier = new(
|
||||
@"^[\s`\[""]*(?<table>[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)",
|
||||
@@ -205,9 +218,14 @@ public static class SqlGuard
|
||||
{
|
||||
var tables = new List<string>();
|
||||
|
||||
if (Describe.Match(normalized) is { Success: true } describe)
|
||||
tables.AddRange(ParseTableList(describe.Groups["rest"].Value).Take(1));
|
||||
|
||||
foreach (Match keyword in TableKeyword.Matches(normalized))
|
||||
{
|
||||
var rest = normalized[(keyword.Index + keyword.Length)..];
|
||||
if (IfExists.Match(rest) is { Success: true } ifExists)
|
||||
rest = rest[ifExists.Length..];
|
||||
tables.AddRange(ParseTableList(rest));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClawdDotNet.Tools.WebFetch;
|
||||
|
||||
/// <summary>
|
||||
/// Holt aus einer grossen JSON-Antwort nur die Eintraege, die einen Suchtext enthalten (#632).
|
||||
///
|
||||
/// <para>Anlass: Die Unwetterwarnungen des DWD kommen als eine Datei fuer ganz Deutschland
|
||||
/// (~60 KB, JSONP). Ein Agent, der nur Berlin brauchte, blaetterte alle fuenf Abschnitte durch -
|
||||
/// 160.000 Tokens, weil jeder Abschnitt bei jedem weiteren Schritt erneut mitgeschickt wird.</para>
|
||||
///
|
||||
/// <para>Gesucht wird ohne Gross-/Kleinschreibung in Werten und Schluesselnamen. Treffer ist das
|
||||
/// innerste Objekt, dessen eigener Wert passt - so kommt eine Warnung als Ganzes mit, nicht nur
|
||||
/// das Feld "regionName". Passt ein Schluesselname (etwa eine Warnzellen-Id), kommt sein Wert mit.
|
||||
/// JSONP ("callback({...});") wird ausgepackt.</para>
|
||||
/// </summary>
|
||||
public static class JsonFilter
|
||||
{
|
||||
public const int MaxTreffer = 200;
|
||||
|
||||
private static readonly Regex Jsonp = new(
|
||||
@"^\s*[A-Za-z_$][\w$.]*\s*\((?<json>.*)\)\s*;?\s*$",
|
||||
RegexOptions.Singleline | RegexOptions.Compiled);
|
||||
|
||||
public static (string Text, string Bereich, string? Hinweis) Anwenden(string body, string filter)
|
||||
{
|
||||
var roh = body.Trim();
|
||||
if (Jsonp.Match(roh) is { Success: true } jsonp)
|
||||
roh = jsonp.Groups["json"].Value;
|
||||
|
||||
JsonNode? wurzel;
|
||||
try
|
||||
{
|
||||
wurzel = JsonNode.Parse(roh);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return (body.Trim(), "roh", "filter nicht angewendet: Der Inhalt ist kein gueltiges JSON - ungefiltert geliefert.");
|
||||
}
|
||||
|
||||
var treffer = new List<object>();
|
||||
var gesamt = 0;
|
||||
Suche(wurzel, "$", filter, treffer, ref gesamt);
|
||||
|
||||
var ergebnis = new { filter, treffer = gesamt, eintraege = treffer };
|
||||
var text = JsonSerializer.Serialize(ergebnis, WebInhalt.JsonOptionen);
|
||||
|
||||
var hinweis = gesamt == 0
|
||||
? $"Keine Eintraege mit '{filter}' gefunden (Antwort insgesamt {roh.Length} Zeichen)."
|
||||
: gesamt > MaxTreffer
|
||||
? $"{gesamt} Treffer fuer '{filter}', die ersten {MaxTreffer} geliefert - grenze den filter ein."
|
||||
: $"{gesamt} Treffer fuer '{filter}' aus {roh.Length} Zeichen Gesamtantwort.";
|
||||
|
||||
return (text, "gefiltert", hinweis);
|
||||
}
|
||||
|
||||
private static void Suche(JsonNode? knoten, string pfad, string filter, List<object> treffer, ref int gesamt)
|
||||
{
|
||||
switch (knoten)
|
||||
{
|
||||
case JsonObject obj:
|
||||
if (obj.Any(p => p.Value is JsonValue v && (Passt(Text(v), filter) || Passt(p.Key, filter))))
|
||||
{
|
||||
Merke(pfad, obj, treffer, ref gesamt);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (schluessel, wert) in obj)
|
||||
{
|
||||
var unterpfad = $"{pfad}.{schluessel}";
|
||||
if (Passt(schluessel, filter) && wert is JsonObject or JsonArray)
|
||||
Merke(unterpfad, wert, treffer, ref gesamt);
|
||||
else
|
||||
Suche(wert, unterpfad, filter, treffer, ref gesamt);
|
||||
}
|
||||
break;
|
||||
|
||||
case JsonArray arr:
|
||||
for (var i = 0; i < arr.Count; i++)
|
||||
{
|
||||
if (arr[i] is JsonValue v && Passt(Text(v), filter))
|
||||
Merke($"{pfad}[{i}]", v, treffer, ref gesamt);
|
||||
else
|
||||
Suche(arr[i], $"{pfad}[{i}]", filter, treffer, ref gesamt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Merke(string pfad, JsonNode wert, List<object> treffer, ref int gesamt)
|
||||
{
|
||||
gesamt++;
|
||||
if (treffer.Count < MaxTreffer)
|
||||
treffer.Add(new { pfad, wert = wert.DeepClone() });
|
||||
}
|
||||
|
||||
private static string Text(JsonValue wert)
|
||||
=> wert.TryGetValue<string>(out var s) ? s : wert.ToJsonString();
|
||||
|
||||
private static bool Passt(string text, string filter)
|
||||
=> text.Contains(filter, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
@@ -21,7 +21,9 @@ public sealed class WebFetchTool : IAgentTool
|
||||
den du brauchst - noch guenstiger. Linkziele (URLs) kommen nur mit links=true - nur
|
||||
setzen, wenn du die Adressen wirklich brauchst. mode="full" liefert den ganzen Seitentext und kostet
|
||||
ein Vielfaches: nur verwenden, wenn der Hauptinhalt fehlt oder unvollstaendig ist.
|
||||
JSON, Text und XML kommen unveraendert. Lange Inhalte kommen in Abschnitten: die Antwort
|
||||
JSON, Text und XML kommen unveraendert. Brauchst du aus einer grossen JSON-Antwort nur
|
||||
bestimmte Eintraege (z.B. eine Stadt), nimm "filter": dann kommen nur die Objekte, die den
|
||||
Text enthalten - viel guenstiger als Blaettern. Lange Inhalte kommen in Abschnitten: die Antwort
|
||||
nennt nextOffset - nur weiterlesen, wenn der Rest fuer den Auftrag wirklich gebraucht wird.
|
||||
|
||||
rss: Titel, Link, Datum und kurze Beschreibung je Eintrag ("limit" begrenzt die Anzahl).
|
||||
@@ -65,6 +67,8 @@ public sealed class WebFetchTool : IAgentTool
|
||||
"description": "fetch: auto (Standard) = Hauptinhalt als Markdown; full = ganzer Seitentext, nur wenn auto nicht reicht" },
|
||||
"links": { "type": "boolean",
|
||||
"description": "fetch: Linkziele als [Text](URL) mitliefern (Standard false - kostet auf linkreichen Seiten viel)" },
|
||||
"filter": { "type": "string",
|
||||
"description": "fetch, nur JSON/JSONP: nur Eintraege liefern, die diesen Text enthalten (ohne Gross-/Kleinschreibung), z.B. 'Berlin'" },
|
||||
"offset": { "type": "integer",
|
||||
"description": "fetch: Fortsetzung eines langen Inhalts - Wert aus nextOffset der vorigen Antwort" },
|
||||
"limit": { "type": "integer",
|
||||
@@ -152,7 +156,8 @@ public sealed class WebFetchTool : IAgentTool
|
||||
GetInt(input, "offset") ?? 0,
|
||||
GetConfigInt(config, "maxContentChars", WebInhalt.StandardMaxZeichen),
|
||||
mitLinks: input.ValueKind == JsonValueKind.Object
|
||||
&& input.TryGetProperty("links", out var l) && l.ValueKind == JsonValueKind.True);
|
||||
&& input.TryGetProperty("links", out var l) && l.ValueKind == JsonValueKind.True,
|
||||
filter: GetString(input, "filter"));
|
||||
|
||||
// Stand der Daten: Last-Modified oder das Datum aus der Seite. Nicht mehr der
|
||||
// Date-Header - der ist die Uhrzeit des Servers beim Abruf, also fetchedAt.
|
||||
|
||||
@@ -57,7 +57,7 @@ public static class WebInhalt
|
||||
|
||||
public static AufbereiteterInhalt Aufbereiten(
|
||||
string body, string? mediaType, Uri quelle, AbrufModus modus, string? selector,
|
||||
int offset = 0, int maxZeichen = StandardMaxZeichen, bool mitLinks = false)
|
||||
int offset = 0, int maxZeichen = StandardMaxZeichen, bool mitLinks = false, string? filter = null)
|
||||
{
|
||||
var art = Art(mediaType, body);
|
||||
|
||||
@@ -66,7 +66,12 @@ public static class WebInhalt
|
||||
string? titel = null, sprache = null, kanonisch = null, hinweis = null;
|
||||
DateTimeOffset? datum = null;
|
||||
|
||||
if (art != "html")
|
||||
if (art != "html" && !string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
(text, bereich, hinweis) = JsonFilter.Anwenden(body, filter.Trim());
|
||||
if (bereich == "gefiltert") art = "json";
|
||||
}
|
||||
else if (art != "html")
|
||||
{
|
||||
text = body.Trim();
|
||||
}
|
||||
@@ -95,10 +100,23 @@ public static class WebInhalt
|
||||
}
|
||||
}
|
||||
|
||||
if (art == "html" && !string.IsNullOrWhiteSpace(filter))
|
||||
{
|
||||
const string nurJson = "filter gilt nur fuer JSON - fuer HTML nimm selector.";
|
||||
hinweis = hinweis is null ? nurJson : $"{hinweis} {nurJson}";
|
||||
}
|
||||
|
||||
var (teil, start, naechster) = Abschnitt(text, offset, maxZeichen);
|
||||
if (naechster is not null)
|
||||
{
|
||||
var fortsetzung = $"Inhalt gekuerzt ({teil.Length} von {text.Length} Zeichen) - mit offset={naechster} weiterlesen, nur falls noetig.";
|
||||
|
||||
// #632: Jeder weitere Abschnitt bleibt im Kontext und wird bei jedem folgenden Schritt
|
||||
// erneut bezahlt. Fuer die DWD-Warnungen las ein Agent fuenf Abschnitte - 160.000 Tokens
|
||||
// fuer eine Frage nach einer einzigen Stadt.
|
||||
if (art == "json" && bereich != "gefiltert")
|
||||
fortsetzung += " Suchst du bestimmte Eintraege, hole sie mit 'filter' (z.B. filter=\"Berlin\") statt weiterzublaettern.";
|
||||
|
||||
hinweis = hinweis is null ? fortsetzung : $"{hinweis} {fortsetzung}";
|
||||
}
|
||||
|
||||
|
||||
@@ -49,4 +49,27 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
|
||||
|
||||
public Task<int> CountPendingAsync(CancellationToken ct)
|
||||
=> Task.FromResult(_calls.Values.Count(c => c.Status == StagingStatus.Pending));
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListPendingForAgentAsync(string agentId, string tool, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StagedCall>>(
|
||||
_calls.Values.Where(c => c.Status == StagingStatus.Pending && c.AgentId == agentId && c.Tool == tool)
|
||||
.OrderBy(c => c.Id).ToList());
|
||||
|
||||
public Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var alt = _calls.Values.Where(c => c.Status == StagingStatus.Pending && c.CreatedAt < createdBefore).ToList();
|
||||
foreach (var c in alt)
|
||||
_calls[c.Id] = c with { Status = StagingStatus.Expired, DecidedAt = now, DecidedBy = "system" };
|
||||
return Task.FromResult(alt.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
|
||||
public void Altern(long id, TimeSpan um)
|
||||
{
|
||||
if (_calls.TryGetValue(id, out var call))
|
||||
_calls[id] = call with { CreatedAt = call.CreatedAt - um };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// Ordnung in der Freigabeliste (#631) und der Freigabeschluessel nach Inhalt (#609).
|
||||
///
|
||||
/// Anlass: Nach knapp drei Tagen Testbetrieb standen 110 Freigaben an. Derselbe
|
||||
/// Loeschvorschlag kam jede Nacht erneut, niemand entschied, und am Ende wurde in 98 Sekunden
|
||||
/// alles freigegeben - 39 Loeschungen scheiterten, weil die Datei schon weg war.
|
||||
/// </summary>
|
||||
public sealed class FreigabeHygieneTests
|
||||
{
|
||||
private static StagingGate Gate(InMemoryStagingRepository repo, string schluessel = "FileRW.delete")
|
||||
=> new(new StagingPolicy(new Dictionary<string, StagingDecision> { [schluessel] = StagingDecision.Approve }), repo);
|
||||
|
||||
private static Task<StagingInterception> Vorlegen(StagingGate gate, string args, string agent = "hausmeister")
|
||||
=> gate.InterceptAsync(agent, "inst", "run", "FileRW", args, CancellationToken.None);
|
||||
|
||||
[Fact]
|
||||
public async Task Derselbe_Vorschlag_wird_nicht_zweimal_angelegt_auch_bei_anderer_Schluesselreihenfolge()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
var erster = await Vorlegen(gate, """{"action":"delete","workspace":"shared","path":"wetter/a.txt"}""");
|
||||
var zweiter = await Vorlegen(gate, """{ "action": "delete", "path": "wetter/a.txt", "workspace": "shared" }""");
|
||||
|
||||
zweiter.Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
zweiter.StagedId.ShouldBe(erster.StagedId);
|
||||
zweiter.Message.ShouldContain($"bereits als #{erster.StagedId}");
|
||||
(await repo.CountPendingAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Andere_Argumente_oder_ein_anderer_Agent_sind_ein_neuer_Vorschlag()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
await Vorlegen(gate, """{"action":"delete","path":"wetter/b.txt"}""");
|
||||
await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""", agent: "redaktion");
|
||||
|
||||
(await repo.CountPendingAsync(default)).ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Ein_entschiedener_Vorschlag_darf_erneut_vorgelegt_werden()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
var erster = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
await repo.TryTransitionAsync(erster.StagedId, StagingStatus.Pending, StagingStatus.Rejected, "mensch", "nein", DateTime.UtcNow, default);
|
||||
var zweiter = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
|
||||
zweiter.StagedId.ShouldNotBe(erster.StagedId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Offene_Vorschlaege_verfallen_nach_der_Frist()
|
||||
{
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var gate = Gate(repo);
|
||||
|
||||
var alt = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
repo.Altern(alt.StagedId, StagingGate.StandardVerfall + TimeSpan.FromMinutes(1));
|
||||
var neu = await Vorlegen(gate, """{"action":"delete","path":"wetter/a.txt"}""");
|
||||
|
||||
(await repo.GetAsync(alt.StagedId, default))!.Status.ShouldBe(StagingStatus.Expired);
|
||||
neu.StagedId.ShouldNotBe(alt.StagedId, "der verfallene Vorschlag zaehlt nicht mehr als Dublette");
|
||||
(await repo.CountPendingAsync(default)).ShouldBe(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kanonische_Form_ignoriert_Reihenfolge_und_Leerraum_aber_nicht_Werte()
|
||||
{
|
||||
StagingGate.Kanonisch("""{"b":1,"a":{"y":2,"x":[1,2]}}""")
|
||||
.ShouldBe(StagingGate.Kanonisch("""{ "a": { "x": [1, 2], "y": 2 }, "b": 1 }"""));
|
||||
StagingGate.Kanonisch("""{"a":1}""").ShouldNotBe(StagingGate.Kanonisch("""{"a":2}"""));
|
||||
}
|
||||
|
||||
/// <summary>Werkzeug, das seinen Freigabeschluessel aus dem Inhalt bestimmt - wie Database.</summary>
|
||||
private sealed class InhaltsTool : IAgentTool
|
||||
{
|
||||
public string Name => "Database";
|
||||
public string Description => "Test";
|
||||
public JsonElement InputSchema { get; } = JsonDocument.Parse("""{"type":"object"}""").RootElement.Clone();
|
||||
public int Aufrufe { get; private set; }
|
||||
|
||||
public string? StagingAktion(JsonElement input)
|
||||
=> input.TryGetProperty("sql", out var sql) && sql.GetString()!.StartsWith("INSERT") ? "write" : "query";
|
||||
|
||||
public Task<ToolResult> ExecuteAsync(JsonElement input, AgentToolContext context, CancellationToken ct)
|
||||
{
|
||||
Aufrufe++;
|
||||
return Task.FromResult(ToolResult.Ok("1 Zeilen betroffen."));
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Der_Freigabeschluessel_des_Werkzeugs_schlaegt_das_action_Argument()
|
||||
{
|
||||
// #609: Dasselbe INSERT lief als action "query" eine Sekunde spaeter ohne Freigabe durch.
|
||||
var repo = new InMemoryStagingRepository();
|
||||
var tool = new InhaltsTool();
|
||||
var fixture = new EngineFixture(staging: Gate(repo, "Database.write")).WithTool(tool);
|
||||
var agent = fixture.AddAgent("datenwart", "Database");
|
||||
fixture.Client
|
||||
.RespondsWithToolCall("Database", """{"action":"query","sql":"INSERT INTO protokoll VALUES (1)"}""")
|
||||
.RespondsWithToolCall("Database", """{"action":"query","sql":"SELECT * FROM protokoll"}""")
|
||||
.RespondsWithText("ERGEBNIS: ok - fertig");
|
||||
|
||||
await fixture.Engine.RunAsync(agent, "los", "inst", default);
|
||||
|
||||
tool.Aufrufe.ShouldBe(1, "nur das SELECT laeuft ohne Freigabe");
|
||||
var offen = await repo.ListPendingAsync(default);
|
||||
offen.ShouldHaveSingleItem().Action.ShouldBe("write");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Core.Tools;
|
||||
using ClawdDotNet.Tools.Database;
|
||||
using ClawdDotNet.Tools.Tests.FileRW;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.Database;
|
||||
|
||||
/// <summary>
|
||||
/// Vorpruefung (#579) und Freigabeschluessel (#609) des Database-Werkzeugs - beides ohne
|
||||
/// Datenbankverbindung pruefbar, weil es nur Konfiguration und Statement ansieht.
|
||||
/// </summary>
|
||||
public sealed class DatabaseVorpruefungTests
|
||||
{
|
||||
private static readonly DatabaseTool Tool = new();
|
||||
|
||||
private static AgentToolContext Kontext(string accessLevel, params string[] tabellen) => new(
|
||||
"agent", "inst",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["type"] = "mysql",
|
||||
["connectionString"] = "Server=nirgends",
|
||||
["accessLevel"] = accessLevel,
|
||||
["allowedTables"] = JsonSerializer.SerializeToElement(tabellen)
|
||||
},
|
||||
new NullStateStore(), NullLogger.Instance, CancellationToken.None);
|
||||
|
||||
private static JsonElement Sql(string sql, string action = "query")
|
||||
=> JsonSerializer.SerializeToElement(new { action, sql });
|
||||
|
||||
[Fact]
|
||||
public void Ein_Agent_mit_Leserechten_bekommt_sein_INSERT_sofort_abgelehnt_statt_zur_Freigabe()
|
||||
=> Tool.Preflight(Sql("INSERT INTO messwerte (ort) VALUES ('Berlin')", "insert"), Kontext("ReadOnly", "messwerte"))
|
||||
.ShouldNotBeNull().ShouldContain("ReadOnly");
|
||||
|
||||
[Fact]
|
||||
public void Strukturaenderung_ohne_Admin_wird_vorab_abgelehnt()
|
||||
=> Tool.Preflight(Sql("ALTER TABLE messwerte ADD COLUMN x INT"), Kontext("ReadWrite", "messwerte"))
|
||||
.ShouldNotBeNull().ShouldContain("DDL");
|
||||
|
||||
[Fact]
|
||||
public void Fremde_Tabelle_wird_vorab_abgelehnt()
|
||||
=> Tool.Preflight(Sql("SELECT * FROM protokoll"), Kontext("ReadOnly", "messwerte"))
|
||||
.ShouldNotBeNull().ShouldContain("protokoll");
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM messwerte", "ReadOnly")]
|
||||
[InlineData("INSERT INTO messwerte (ort) VALUES ('Berlin')", "ReadWrite")]
|
||||
[InlineData("CREATE TABLE IF NOT EXISTS messwerte (id INT)", "Admin")]
|
||||
[InlineData("DESCRIBE messwerte", "ReadOnly")]
|
||||
public void Erlaubtes_passiert_die_Vorpruefung(string sql, string level)
|
||||
=> Tool.Preflight(Sql(sql), Kontext(level, "messwerte")).ShouldBeNull();
|
||||
|
||||
[Theory]
|
||||
[InlineData("SELECT * FROM messwerte", "query", "query")]
|
||||
[InlineData("INSERT INTO messwerte (ort) VALUES ('Berlin')", "query", "write")]
|
||||
[InlineData("UPDATE messwerte SET ort = 'X'", "insert", "write")]
|
||||
[InlineData("CREATE TABLE messwerte (id INT)", "query", "schema")]
|
||||
[InlineData("DROP TABLE IF EXISTS messwerte", "query", "schema")]
|
||||
public void Der_Freigabeschluessel_folgt_dem_Statement_nicht_dem_action_Argument(string sql, string action, string erwartet)
|
||||
=> Tool.StagingAktion(Sql(sql, action)).ShouldBe(erwartet);
|
||||
|
||||
[Fact]
|
||||
public void Ohne_sql_gilt_das_action_Argument_wie_bei_MongoDB()
|
||||
=> Tool.StagingAktion(JsonSerializer.SerializeToElement(new { action = "insert", collection = "c" })).ShouldBeNull();
|
||||
}
|
||||
@@ -22,6 +22,30 @@ public sealed class SqlGuardTests
|
||||
// Angriffe — alle müssen abgelehnt werden
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
// ─── #610: wiederholbares Anlegen und Strukturabfragen ───
|
||||
|
||||
[Theory]
|
||||
[InlineData("CREATE TABLE IF NOT EXISTS prices (id INT)", SqlOperation.Schema)]
|
||||
[InlineData("DROP TABLE IF EXISTS prices", SqlOperation.Schema)]
|
||||
[InlineData("DESCRIBE prices", SqlOperation.Read)]
|
||||
[InlineData("DESC prices", SqlOperation.Read)]
|
||||
[InlineData("SHOW COLUMNS FROM prices", SqlOperation.Read)]
|
||||
public void IF_NOT_EXISTS_und_Strukturabfragen_nennen_die_richtige_Tabelle(string sql, SqlOperation operation)
|
||||
{
|
||||
var result = Inspect(sql);
|
||||
|
||||
result.IsValid.ShouldBeTrue(result.Error);
|
||||
result.Operation.ShouldBe(operation);
|
||||
result.Tables.ShouldBe(["prices"]);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CREATE TABLE IF NOT EXISTS users (id INT)")]
|
||||
[InlineData("DROP TABLE IF EXISTS users")]
|
||||
[InlineData("DESCRIBE users")]
|
||||
public void IF_EXISTS_und_DESCRIBE_oeffnen_keine_fremde_Tabelle(string sql)
|
||||
=> Inspect(sql).IsValid.ShouldBeFalse();
|
||||
|
||||
[Theory]
|
||||
[InlineData("DELETE FROM users -- prices")]
|
||||
[InlineData("DELETE FROM users /* prices */")]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using System.Text.Json;
|
||||
using ClawdDotNet.Tools.WebFetch;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Tools.Tests.WebFetch;
|
||||
|
||||
/// <summary>
|
||||
/// #632: Die DWD-Unwetterwarnungen (JSONP, ganz Deutschland) wurden abschnittsweise
|
||||
/// vollstaendig gelesen - 160.000 Tokens fuer eine einzige Stadt. Mit filter kommt nur,
|
||||
/// was passt.
|
||||
/// </summary>
|
||||
public sealed class JsonFilterTests
|
||||
{
|
||||
private static readonly Uri Quelle = new("https://www.dwd.de/DWD/warnungen/warnapp/json/warnings.json");
|
||||
|
||||
private static string DwdAehnlich(int fuellWarnungen = 0)
|
||||
{
|
||||
var fuell = string.Join(",", Enumerable.Range(1, fuellWarnungen).Select(i =>
|
||||
$$"""{"regionName":"Kreis {{i}}","event":"WINDBÖEN","level":2,"start":1726500000000}"""));
|
||||
return $$"""
|
||||
warnWetter.loadWarnings({"time":1726650000000,"warnings":{
|
||||
"111000000":[{"regionName":"Berlin","event":"STARKREGEN","level":3,"start":1726650000000}],
|
||||
"105000000":[{"regionName":"Hamburg","event":"FROST","level":1,"start":1726650000000}{{(fuell.Length > 0 ? "," + fuell : "")}}]
|
||||
},"vorabInformation":{},"copyright":"DWD"});
|
||||
""";
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void JSONP_wird_ausgepackt_und_nur_die_passende_Warnung_geliefert()
|
||||
{
|
||||
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(), "application/javascript", Quelle, AbrufModus.Auto, null, filter: "berlin");
|
||||
|
||||
inhalt.Art.ShouldBe("json");
|
||||
inhalt.Bereich.ShouldBe("gefiltert");
|
||||
using var doc = JsonDocument.Parse(inhalt.Text);
|
||||
doc.RootElement.GetProperty("treffer").GetInt32().ShouldBe(1);
|
||||
var eintrag = doc.RootElement.GetProperty("eintraege")[0];
|
||||
eintrag.GetProperty("pfad").GetString().ShouldBe("$.warnings.111000000[0]");
|
||||
eintrag.GetProperty("wert").GetProperty("event").GetString().ShouldBe("STARKREGEN");
|
||||
inhalt.Text.ShouldNotContain("Hamburg");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ein_passender_Schluesselname_liefert_seinen_Wert()
|
||||
{
|
||||
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(), "application/json", Quelle, AbrufModus.Auto, null, filter: "1110");
|
||||
|
||||
inhalt.Text.ShouldContain("STARKREGEN");
|
||||
inhalt.Text.ShouldNotContain("FROST");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Das_gefilterte_Ergebnis_ist_ein_Bruchteil_der_ganzen_Antwort()
|
||||
{
|
||||
var gross = DwdAehnlich(fuellWarnungen: 800);
|
||||
var inhalt = WebInhalt.Aufbereiten(gross, "application/json", Quelle, AbrufModus.Auto, null, filter: "Berlin");
|
||||
|
||||
gross.Length.ShouldBeGreaterThan(60_000);
|
||||
inhalt.Text.Length.ShouldBeLessThan(400);
|
||||
inhalt.NaechsterOffset.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ohne_Treffer_sagt_der_Hinweis_das_ausdruecklich()
|
||||
{
|
||||
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(), "application/json", Quelle, AbrufModus.Auto, null, filter: "Muenchen");
|
||||
|
||||
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("Keine Eintraege");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Kein_gueltiges_JSON_wird_ungefiltert_mit_Hinweis_geliefert()
|
||||
{
|
||||
var inhalt = WebInhalt.Aufbereiten("Das ist Text, kein JSON.", "text/plain", Quelle, AbrufModus.Auto, null, filter: "Berlin");
|
||||
|
||||
inhalt.Text.ShouldBe("Das ist Text, kein JSON.");
|
||||
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("kein gueltiges JSON");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Blaettert_ein_Agent_durch_grosses_JSON_empfiehlt_der_Hinweis_den_filter()
|
||||
{
|
||||
var inhalt = WebInhalt.Aufbereiten(DwdAehnlich(fuellWarnungen: 800), "application/json", Quelle, AbrufModus.Auto, null);
|
||||
|
||||
inhalt.NaechsterOffset.ShouldNotBeNull();
|
||||
inhalt.Hinweis.ShouldNotBeNull().ShouldContain("filter");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user