Freigabe-Hygiene (#631, #579, #609, #610) und JSON-Filter fuer WebFetch (#632)

#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:
Richard
2026-09-18 18:51:06 +02:00
co-authored by Claude Opus 5
parent f5731c50f8
commit f74e49ba35
18 changed files with 727 additions and 29 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand ClawdDotNet.Core.BuildInfo bleibt daneben bestehen: das ist ein von Hand
gefuehrter Zaehler mit Aenderungstext, keine Versionsangabe. 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. Symbole in die Assembly einbetten statt als .pdb daneben zu legen.
+2 -1
View File
@@ -1393,7 +1393,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
if (_stagingGate is not null) if (_stagingGate is not null)
{ {
var intercept = await _stagingGate.InterceptAsync( 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) if (intercept.Outcome == Staging.StagingOutcome.Denied)
{ {
@@ -106,6 +106,38 @@ public sealed class SqliteStagingRepository : IStagingRepository
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct)); 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 ─── // ─── Hilfsfunktionen ───
private const string Columns = private const string Columns =
+81 -3
View File
@@ -1,4 +1,5 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
namespace ClawdDotNet.Core.Staging; namespace ClawdDotNet.Core.Staging;
@@ -28,20 +29,42 @@ public readonly record struct StagingInterception(StagingOutcome Outcome, string
/// </summary> /// </summary>
public sealed class StagingGate 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 StagingPolicy _policy;
private readonly IStagingRepository _repo; 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; _policy = policy;
_repo = repo; _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( public async Task<StagingInterception> InterceptAsync(
string agentId, string instanceId, string runId, 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)) switch (_policy.Decide(tool, action))
{ {
@@ -55,6 +78,22 @@ public sealed class StagingGate
0); 0);
case StagingDecision.Approve: 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 var id = await _repo.AppendAsync(new StagedCall
{ {
RunId = runId, 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> /// <summary>Liest das <c>action</c>-Argument, wenn vorhanden — der Aktionsschlüssel der Policy.</summary>
public static string? ExtractAction(string argumentsJson) public static string? ExtractAction(string argumentsJson)
{ {
+16 -1
View File
@@ -29,7 +29,13 @@ public enum StagingStatus
Failed, Failed,
/// <summary>Abgelehnt.</summary> /// <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> /// <summary>
@@ -92,4 +98,13 @@ public interface IStagingRepository
Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct); Task FinalizeAsync(long id, StagingStatus status, string? resultRef, DateTime now, CancellationToken ct);
Task<int> CountPendingAsync(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, ["Telegram.send_message"] = StagingDecision.Approve,
["Database.insert"] = StagingDecision.Approve, ["Database.insert"] = StagingDecision.Approve,
["Database.upsert"] = 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, ["FileRW.delete"] = StagingDecision.Approve,
["FTP.upload"] = StagingDecision.Approve, ["FTP.upload"] = StagingDecision.Approve,
["FTP.delete"] = StagingDecision.Approve, ["FTP.delete"] = StagingDecision.Approve,
@@ -43,7 +43,13 @@ public sealed class StagingService
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging"); _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<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct); public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
+8
View File
@@ -57,6 +57,14 @@ public interface IAgentTool
/// </summary> /// </summary>
string? Preflight(JsonElement input, AgentToolContext context) => null; 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( Task<ToolResult> ExecuteAsync(
JsonElement input, JsonElement input,
AgentToolContext context, AgentToolContext context,
+98 -18
View File
@@ -22,7 +22,19 @@ public sealed class DatabaseTool : IAgentTool
{ {
public string Name => "Database"; 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(""" public JsonElement InputSchema { get; } = JsonDocument.Parse("""
{ {
@@ -54,6 +66,73 @@ public sealed class DatabaseTool : IAgentTool
} }
""").RootElement.Clone(); """).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( public async Task<ToolResult> ExecuteAsync(
JsonElement input, JsonElement input,
AgentToolContext context, AgentToolContext context,
@@ -62,7 +141,7 @@ public sealed class DatabaseTool : IAgentTool
var action = input.GetProperty("action").GetString() var action = input.GetProperty("action").GetString()
?? throw new ArgumentException("'action' is required"); ?? 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; var connectionString = context.ToolConfig.TryGetValue("connectionString", out var cs) ? cs?.ToString() : null;
if (string.IsNullOrWhiteSpace(type) || string.IsNullOrWhiteSpace(connectionString)) 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. // werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context)); var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
if (!inspection.IsValid) if (Verweigerung(inspection, accessLevel) is { } verweigert)
return ToolResult.Fail($"Sicherheitsfehler: {inspection.Error}"); return ToolResult.Fail(verweigert);
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).");
}
using DbConnection conn = type switch using DbConnection conn = type switch
{ {
@@ -125,7 +193,9 @@ public sealed class DatabaseTool : IAgentTool
using var cmd = conn.CreateCommand(); using var cmd = conn.CreateCommand();
cmd.CommandText = sql; 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); using var reader = await cmd.ExecuteReaderAsync(ct);
var results = new List<Dictionary<string, object>>(); var results = new List<Dictionary<string, object>>();
@@ -143,7 +213,9 @@ public sealed class DatabaseTool : IAgentTool
else else
{ {
var affected = await cmd.ExecuteNonQueryAsync(ct); 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) if (context.ToolConfig.TryGetValue("accessLevel", out var val) && val != null)
{ {
@@ -64,6 +64,19 @@ public static class SqlGuard
@"ADD|DROP|RENAME|MODIFY|ALTER)\b", @"ADD|DROP|RENAME|MODIFY|ALTER)\b",
RegexOptions.IgnoreCase | RegexOptions.Compiled); 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> /// <summary>Erster Bezeichner eines Listeneintrags — ein evtl. folgender Alias wird ignoriert.</summary>
private static readonly Regex LeadingIdentifier = new( private static readonly Regex LeadingIdentifier = new(
@"^[\s`\[""]*(?<table>[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)", @"^[\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>(); 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)) foreach (Match keyword in TableKeyword.Matches(normalized))
{ {
var rest = normalized[(keyword.Index + keyword.Length)..]; var rest = normalized[(keyword.Index + keyword.Length)..];
if (IfExists.Match(rest) is { Success: true } ifExists)
rest = rest[ifExists.Length..];
tables.AddRange(ParseTableList(rest)); 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 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 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. 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. 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). 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" }, "description": "fetch: auto (Standard) = Hauptinhalt als Markdown; full = ganzer Seitentext, nur wenn auto nicht reicht" },
"links": { "type": "boolean", "links": { "type": "boolean",
"description": "fetch: Linkziele als [Text](URL) mitliefern (Standard false - kostet auf linkreichen Seiten viel)" }, "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", "offset": { "type": "integer",
"description": "fetch: Fortsetzung eines langen Inhalts - Wert aus nextOffset der vorigen Antwort" }, "description": "fetch: Fortsetzung eines langen Inhalts - Wert aus nextOffset der vorigen Antwort" },
"limit": { "type": "integer", "limit": { "type": "integer",
@@ -152,7 +156,8 @@ public sealed class WebFetchTool : IAgentTool
GetInt(input, "offset") ?? 0, GetInt(input, "offset") ?? 0,
GetConfigInt(config, "maxContentChars", WebInhalt.StandardMaxZeichen), GetConfigInt(config, "maxContentChars", WebInhalt.StandardMaxZeichen),
mitLinks: input.ValueKind == JsonValueKind.Object 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 // 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. // Date-Header - der ist die Uhrzeit des Servers beim Abruf, also fetchedAt.
+20 -2
View File
@@ -57,7 +57,7 @@ public static class WebInhalt
public static AufbereiteterInhalt Aufbereiten( public static AufbereiteterInhalt Aufbereiten(
string body, string? mediaType, Uri quelle, AbrufModus modus, string? selector, 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); var art = Art(mediaType, body);
@@ -66,7 +66,12 @@ public static class WebInhalt
string? titel = null, sprache = null, kanonisch = null, hinweis = null; string? titel = null, sprache = null, kanonisch = null, hinweis = null;
DateTimeOffset? datum = 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(); 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); var (teil, start, naechster) = Abschnitt(text, offset, maxZeichen);
if (naechster is not null) if (naechster is not null)
{ {
var fortsetzung = $"Inhalt gekuerzt ({teil.Length} von {text.Length} Zeichen) - mit offset={naechster} weiterlesen, nur falls noetig."; 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}"; hinweis = hinweis is null ? fortsetzung : $"{hinweis} {fortsetzung}";
} }
@@ -49,4 +49,27 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
public Task<int> CountPendingAsync(CancellationToken ct) public Task<int> CountPendingAsync(CancellationToken ct)
=> Task.FromResult(_calls.Values.Count(c => c.Status == StagingStatus.Pending)); => 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 // 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] [Theory]
[InlineData("DELETE FROM users -- prices")] [InlineData("DELETE FROM users -- prices")]
[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");
}
}