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
@@ -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");
}
}