Freigaben buendeln statt Weckflut (#643), Freigabemodell ueber Werkzeugrechte (#641)

#643: Entscheidungen wecken den Agenten nur noch bei Ablehnung oder gescheiterter
Ausfuehrung, gesammelt in einer Aufgabe je Agent (5 min Ruhefenster), frisch per
@new:agent statt im fortgefuehrten Chatverlauf. Eine einmalige Aufgabe, deren Lauf
wegen Budget oder Herunterfahren gar nicht stattfand, bleibt faellig (Wiedervorlage
nach 30 min) statt mit gesetztem Marker liegenzubleiben.

#641: Rechte stehen in der Werkzeugkonfiguration des Agenten (erlaubt/verboten).
Erlaubtes laeuft, Nicht-Erlaubtes wird einzeln vorgelegt statt verweigert, Verbotenes
gar nicht erst. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare
Dauerfreigabe je Agent/Werkzeug/Operation; nach drei gleichartigen Freigaben schlaegt
die Oberflaeche sie vor. Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig.
Database: Operation ist das SQL-Schluesselwort; die eigene Zugriffspruefung greift nur
noch ohne Freigabestelle (AgentToolContext.FreigabeGeprueft). WebUI: Rechte-Tabelle je
Werkzeug, Dauerfreigaben mit Widerruf.

Version 0.1.11.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-19 14:37:44 +02:00
co-authored by Claude Opus 5
parent f74e49ba35
commit 2f0361278b
36 changed files with 1687 additions and 137 deletions
@@ -167,6 +167,45 @@ public sealed class AgentAdministrationTests
host.Host.AgentAdmin.Find("scout")!.Tools["FileRW"].ShouldBeEmpty();
}
// ─── #641: Rechte in der Werkzeugkonfiguration ───
[Fact]
public void Rechte_werden_normalisiert_gespeichert_und_ueberleben_den_Neustart()
{
using var host = new TestHost();
host.Host.AgentAdmin.Add(new NewAgent("scout", "Scout", "modell"));
host.Host.AgentAdmin.SetToolEnabled("scout", "Database", enabled: true);
host.Host.AgentAdmin.SetToolRights("scout", "Database", ["SELECT", "Insert", "drop"], ["drop"]).Ok.ShouldBeTrue();
var rechte = ClawdDotNet.Core.Staging.Werkzeugrechte.Aus(
host.Host.Directories.LoadInstanceConfig(host.Host.InstancePath).Agents
.Single(a => a.AgentId == "scout").Tools["Database"].AsReadOnly());
rechte.Erlaubt.ShouldNotBeNull().OrderBy(x => x).ToArray().ShouldBe(["insert", "select"], "verboten schlaegt erlaubt");
rechte.IstVerboten("DROP").ShouldBeTrue();
}
[Fact]
public void Einstellungen_speichern_loescht_die_Rechte_nicht()
{
// Die Einstellungsmasken schreiben ein frisches Woerterbuch ohne "erlaubt".
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.SetToolEnabled("scout", "Database", enabled: true);
host.Host.AgentAdmin.SetToolRights("scout", "Database", ["select"], []);
host.Host.AgentAdmin.SetToolSettings("scout", "Database", new Dictionary<string, object?> { ["type"] = "mysql" });
host.Host.AgentAdmin.Find("scout")!.Tools["Database"].ShouldContainKey("erlaubt");
}
[Fact]
public void Rechte_nur_fuer_zugewiesene_Werkzeuge()
{
using var host = new TestHost(TestHost.Agent("scout"));
host.Host.AgentAdmin.SetToolRights("scout", "Database", ["select"], []).Ok.ShouldBeFalse();
}
[Fact]
public void Ein_Werkzeug_wegnehmen_entfernt_auch_seine_Einstellungen()
{
@@ -20,6 +20,12 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
public Task<StagedCall?> GetAsync(long id, CancellationToken ct)
=> Task.FromResult(_calls.GetValueOrDefault(id));
public Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
=> Task.FromResult(_calls.Values.Count(c =>
c.Status == StagingStatus.Executed && c.AgentId == agentId && c.Tool == tool
&& string.Equals(c.Action ?? "", action ?? "", StringComparison.OrdinalIgnoreCase)
&& c.DecidedAt >= since));
public Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
=> Task.FromResult<IReadOnlyList<StagedCall>>(
_calls.Values.Where(c => c.Status == StagingStatus.Pending).OrderBy(c => c.Id).ToList());
@@ -66,17 +66,78 @@ public sealed class StagingServiceTests : IDisposable
}
[Fact]
public async Task Freigabe_weckt_den_Agenten_ueber_einen_Folge_Task()
public async Task Eine_erfolgreich_ausgefuehrte_Freigabe_weckt_niemanden()
{
// #643: Vorher weckte jede Entscheidung den Agenten - 110 Freigaben, 110 Laeufe.
var id = await Stage();
await _service.ApproveAsync(id, "richard", default);
var tasks = await _tasks.ListAsync(new TaskQuery(), default);
tasks.Count.ShouldBe(1);
tasks[0].Assignee.ShouldBe("@agent-a", "der vorschlagende Agent wird mit seinem Kontext geweckt");
tasks[0].Status.ShouldBe(TaskItemStatus.Todo);
tasks[0].Body.ShouldContain("freigegeben");
(await _tasks.ListAsync(new TaskQuery(), default)).ShouldBeEmpty();
}
[Fact]
public async Task Viele_Entscheidungen_landen_in_einer_frischen_Sammelaufgabe()
{
var abgelehnt = await Stage("{\"action\":\"send\",\"to\":\"a@b.de\"}");
var ok1 = await Stage("{\"action\":\"send\",\"to\":\"c@d.de\"}");
var ok2 = await Stage("{\"action\":\"send\",\"to\":\"e@f.de\"}");
await _service.RejectAsync(abgelehnt, "richard", "falscher Empfaenger", default);
await _service.ApproveAsync(ok1, "richard", default);
await _service.ApproveAsync(ok2, "richard", default);
var task = (await _tasks.ListAsync(new TaskQuery(), default)).ShouldHaveSingleItem();
task.Title.ShouldBe(StagingService.SammelTitel);
task.Assignee.ShouldBe("@new:agent-a", "frisch wecken, nicht den Chatverlauf fortfuehren");
task.Body.ShouldContain($"#{abgelehnt} Mail.send: abgelehnt");
task.Body.ShouldContain("falscher Empfaenger");
task.Body.ShouldContain($"#{ok1} Mail.send: freigegeben und ausgeführt");
task.Body.ShouldContain($"#{ok2} Mail.send: freigegeben und ausgeführt");
}
[Fact]
public async Task Die_Sammelaufgabe_wird_erst_nach_einer_Ruhepause_faellig()
{
var id = await Stage();
var vorher = DateTime.UtcNow;
await _service.RejectAsync(id, "richard", "nein", default);
var task = (await _tasks.ListAsync(new TaskQuery(), default)).Single();
task.When.ShouldNotBeNull().Kind.ShouldBe(TaskWhenKind.At);
TaskSchedule.DueOccurrence(task, vorher).ShouldBeNull("im Buendelfenster noch nicht faellig");
TaskSchedule.DueOccurrence(task, vorher + StagingService.StandardBuendelFenster + TimeSpan.FromMinutes(1))
.ShouldNotBeNull();
}
[Fact]
public async Task Laeuft_die_Sammelaufgabe_schon_entsteht_fuer_Neues_eine_zweite()
{
var erste = await Stage();
await _service.RejectAsync(erste, "richard", "nein", default);
var task = (await _tasks.ListAsync(new TaskQuery(), default)).Single();
(await _tasks.TryClaimAsync(task.Id, "2026-01-01T00:00:00.0000000Z", "tok", DateTime.UtcNow, DateTime.UtcNow.AddMinutes(-15), default))
.ShouldBeTrue();
var zweite = await Stage("{\"action\":\"send\",\"to\":\"z@z.de\"}");
await _service.RejectAsync(zweite, "richard", "auch nein", default);
var alle = await _tasks.ListAsync(new TaskQuery(), default);
alle.Count.ShouldBe(2);
alle.Single(t => t.Id != task.Id).Body.ShouldContain($"#{zweite}");
}
[Fact]
public async Task Eine_lange_Entscheidungsrunde_bleibt_ein_kleiner_Auftrag()
{
for (var i = 0; i < 60; i++)
await _service.RejectAsync(await Stage($"{{\"action\":\"send\",\"to\":\"x{i}@y.de\"}}"), "richard", "nein", default);
var task = (await _tasks.ListAsync(new TaskQuery(), default)).ShouldHaveSingleItem();
task.Body.Length.ShouldBeLessThan(20_000);
(task.Body.Split("### #").Length - 1).ShouldBe(60, "jede Entscheidung steht drin");
(task.Body.Split("<untrusted_content").Length - 1).ShouldBe(20, "ausfuehrlich nur die ersten");
}
[Fact]
@@ -104,6 +165,7 @@ public sealed class StagingServiceTests : IDisposable
(await _staging.GetAsync(id, default))!.Status.ShouldBe(StagingStatus.Rejected);
var tasks = await _tasks.ListAsync(new TaskQuery(), default);
tasks[0].Assignee.ShouldBe("@new:agent-a");
tasks[0].Body.ShouldContain("abgelehnt");
tasks[0].Body.ShouldContain("zu riskant");
_audit.Entries.Single().Summary.ShouldContain("Abgelehnt von richard");
@@ -157,7 +219,7 @@ public sealed class StagingServiceTests : IDisposable
entry.Summary.ShouldContain("gescheitert");
var body = (await _tasks.ListAsync(new TaskQuery(), default))[0].Body;
body.ShouldContain("Ausführung ist aber gescheitert");
body.ShouldContain("die Ausführung ist aber gescheitert");
body.ShouldContain("<untrusted_content source=\"Mail\">", Case.Sensitive);
}
@@ -0,0 +1,212 @@
using System.Text.Json;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Storage;
using ClawdDotNet.Core.Tasks;
using ClawdDotNet.Core.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
namespace ClawdDotNet.Core.Tests.Staging;
/// <summary>
/// Das Freigabemodell #641: Rechte stehen in der Werkzeugkonfiguration des Agenten. Was
/// erlaubt ist, laeuft; was nicht erlaubt ist, wird einzeln vorgelegt; was verboten ist, wird
/// abgelehnt. "Gleichartiges kuenftig erlauben" erteilt eine widerrufbare Dauerfreigabe.
/// Nur Aenderungen an Agenten bleiben ausnahmslos freigabepflichtig.
///
/// Anlass: 110 Freigaben in zwei Tagen, die die Arbeit blockierten und am Ende ungelesen
/// durchgeklickt wurden.
/// </summary>
public sealed class WerkzeugrechteTests : IDisposable
{
private readonly string _directory;
private readonly SqliteStorage _storage;
private readonly SqliteDauerfreigabeRepository _dauer;
private readonly InMemoryStagingRepository _repo = new();
public WerkzeugrechteTests()
{
_directory = Path.Combine(Path.GetTempPath(), "clawd-tests", Guid.NewGuid().ToString("N"));
_storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
_dauer = new SqliteDauerfreigabeRepository(_storage);
}
public void Dispose()
{
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
try { Directory.Delete(_directory, recursive: true); }
catch { /* Aufräumen ist Nebensache */ }
}
private static Werkzeugrechte Rechte(string[]? erlaubt = null, string[]? verboten = null)
=> Werkzeugrechte.Aus(new Dictionary<string, object?>
{
[Werkzeugrechte.ErlaubtSchluessel] = erlaubt is null ? null : JsonSerializer.SerializeToElement(erlaubt),
[Werkzeugrechte.VerbotenSchluessel] = verboten is null ? null : JsonSerializer.SerializeToElement(verboten)
}.Where(kv => kv.Value is not null).ToDictionary());
private StagingGate Gate() => new(new StagingPolicy(), _repo, dauerfreigaben: _dauer);
private Task<StagingInterception> Aufruf(
string operation, Werkzeugrechte rechte, string agent = "datenwart", string tool = "Database",
StagingDecision? vorgabe = StagingDecision.Approve)
=> Gate().InterceptAsync(agent, "inst", "run", tool,
$$"""{"action":"query","sql":"{{operation}} ..."}""", CancellationToken.None, operation, rechte, vorgabe);
// ─── Einstufung aus der Werkzeugkonfiguration ───
[Theory]
[InlineData("select")]
[InlineData("insert")]
[InlineData("update")]
public async Task Erlaubte_Operationen_laufen_ohne_Rueckfrage(string operation)
=> (await Aufruf(operation, Rechte(["select", "insert", "update"]))).Outcome.ShouldBe(StagingOutcome.Proceed);
[Fact]
public async Task Eine_nicht_erlaubte_Operation_wird_einzeln_vorgelegt_statt_verweigert()
{
// Das Beispiel aus der Entscheidung: darf SELECT, INSERT, UPDATE - braucht einmal ALTER.
var ergebnis = await Aufruf("alter", Rechte(["select", "insert", "update"]));
ergebnis.Outcome.ShouldBe(StagingOutcome.Staged);
ergebnis.Message.ShouldContain("nicht zu den erlaubten Operationen");
(await _repo.CountPendingAsync(default)).ShouldBe(1);
}
[Fact]
public async Task Eine_verbotene_Operation_wird_gar_nicht_erst_vorgelegt()
{
var ergebnis = await Aufruf("drop", Rechte(["select"], verboten: ["drop", "truncate"]));
ergebnis.Outcome.ShouldBe(StagingOutcome.Denied);
(await _repo.CountPendingAsync(default)).ShouldBe(0);
}
[Fact]
public async Task Ein_Stern_erlaubt_alles_ausser_Verbotenem()
{
(await Aufruf("alter", Rechte(["*"], verboten: ["drop"]))).Outcome.ShouldBe(StagingOutcome.Proceed);
(await Aufruf("drop", Rechte(["*"], verboten: ["drop"]))).Outcome.ShouldBe(StagingOutcome.Denied);
}
[Fact]
public async Task Ohne_erlaubt_gilt_die_Vorgabe_des_Werkzeugs_wie_bisher()
{
(await Aufruf("select", Werkzeugrechte.Keine, vorgabe: StagingDecision.Auto)).Outcome.ShouldBe(StagingOutcome.Proceed);
(await Aufruf("insert", Werkzeugrechte.Keine, vorgabe: StagingDecision.Approve)).Outcome.ShouldBe(StagingOutcome.Staged);
}
[Fact]
public async Task Ohne_erlaubt_und_ohne_Vorgabe_gilt_die_Standardregel()
{
var mail = await Gate().InterceptAsync("a", "i", "r", "Mail", """{"action":"send"}""", default, null, Werkzeugrechte.Keine);
mail.Outcome.ShouldBe(StagingOutcome.Staged);
}
[Fact]
public void Die_Konfiguration_wird_in_jeder_Schreibweise_gelesen()
{
Werkzeugrechte.Aus(new Dictionary<string, object?> { ["erlaubt"] = "SELECT, Insert" })
.IstErlaubt("insert").ShouldBeTrue();
Werkzeugrechte.Aus(new Dictionary<string, object?> { ["erlaubt"] = new List<object?> { "delete" } })
.IstErlaubt("DELETE").ShouldBeTrue();
Werkzeugrechte.Aus(new Dictionary<string, object?>()).IstKonfiguriert.ShouldBeFalse();
}
// ─── Agenten veraendern: immer einzeln ───
[Theory]
[InlineData("update_soul")]
[InlineData("update_identity")]
[InlineData("create_agent")]
public async Task Aenderungen_an_Agenten_bleiben_trotz_Stern_und_Dauerfreigabe_freigabepflichtig(string aktion)
{
await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "a", Tool = "AgentEditor", Operation = aktion, ErteiltVon = "x" }, default);
var ergebnis = await Gate().InterceptAsync("a", "i", "r", "AgentEditor",
$$"""{"action":"{{aktion}}"}""", default, aktion, Rechte(["*"]));
ergebnis.Outcome.ShouldBe(StagingOutcome.Staged);
ergebnis.Message.ShouldContain("Änderungen an Agenten");
}
// ─── Dauerfreigaben ───
[Fact]
public async Task Eine_Dauerfreigabe_laesst_die_Operation_dieses_Agenten_kuenftig_laufen()
{
await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "datenwart", Tool = "Database", Operation = "insert", ErteiltVon = "richard" }, default);
(await Aufruf("insert", Rechte(["select"]))).Outcome.ShouldBe(StagingOutcome.Proceed);
(await Aufruf("delete", Rechte(["select"]))).Outcome.ShouldBe(StagingOutcome.Staged, "andere Operation");
(await Aufruf("insert", Rechte(["select"]), agent: "datenleser")).Outcome.ShouldBe(StagingOutcome.Staged, "anderer Agent");
}
[Fact]
public async Task Eine_Dauerfreigabe_hebt_kein_Verbot_auf()
{
await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "datenwart", Tool = "Database", Operation = "drop", ErteiltVon = "richard" }, default);
(await Aufruf("drop", Rechte(["select"], verboten: ["drop"]))).Outcome.ShouldBe(StagingOutcome.Denied);
}
[Fact]
public async Task Nach_dem_Widerruf_wird_wieder_vorgelegt()
{
var d = await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "datenwart", Tool = "Database", Operation = "insert", ErteiltVon = "richard" }, default);
(await _dauer.WiderrufenAsync(d.Id, "richard", DateTime.UtcNow, default)).ShouldBeTrue();
(await Aufruf("insert", Rechte(["select"]))).Outcome.ShouldBe(StagingOutcome.Staged);
(await _dauer.ListAsync(mitWiderrufenen: true, default)).Single().Gilt.ShouldBeFalse();
}
[Fact]
public async Task Dieselbe_Dauerfreigabe_entsteht_nur_einmal()
{
var a = await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "w", Tool = "FileRW", Operation = "Delete", ErteiltVon = "x" }, default);
var b = await _dauer.ErteilenAsync(new Dauerfreigabe { AgentId = "w", Tool = "FileRW", Operation = "delete", ErteiltVon = "y" }, default);
b.Id.ShouldBe(a.Id);
(await _dauer.ListAsync(false, default)).Count.ShouldBe(1);
}
[Fact]
public async Task Freigeben_mit_Gleichartiges_erlauben_erteilt_die_Dauerfreigabe()
{
var staging = new SqliteStagingRepository(_storage);
var service = new StagingService(staging, new Ausfuehrer(), new TaskboardService(new SqliteTaskRepository(_storage),
Path.Combine(_directory, "tasks")), NullLoggerFactory.Instance, dauerfreigaben: _dauer);
var id = await staging.AppendAsync(new StagedCall { RunId = "r", AgentId = "datenwart", Tool = "Database", Action = "insert", ArgumentsJson = "{}" }, default);
await service.ApproveAsync(id, "richard", gleichartigErlauben: true, default);
var d = (await service.ListDauerfreigabenAsync(false, default)).ShouldHaveSingleItem();
d.AgentId.ShouldBe("datenwart");
d.Operation.ShouldBe("insert");
d.AnlassId.ShouldBe(id);
}
[Fact]
public async Task Nach_mehreren_gleichartigen_Freigaben_wird_die_Dauerfreigabe_vorgeschlagen()
{
var staging = new SqliteStagingRepository(_storage);
var service = new StagingService(staging, new Ausfuehrer(), new TaskboardService(new SqliteTaskRepository(_storage),
Path.Combine(_directory, "tasks")), NullLoggerFactory.Instance, dauerfreigaben: _dauer);
StagedCall Vorschlag() => new() { RunId = "r", AgentId = "datenwart", Tool = "Database", Action = "insert", ArgumentsJson = "{}" };
for (var i = 0; i < StagingService.VorschlagAb; i++)
await service.ApproveAsync(await staging.AppendAsync(Vorschlag(), default), "richard", default);
var offen = (await staging.GetAsync(await staging.AppendAsync(Vorschlag(), default), default))!;
(await service.BisherFreigegebenAsync(offen, default)).ShouldBe(StagingService.VorschlagAb);
var soul = offen with { Tool = "AgentEditor", Action = "update_soul" };
StagingService.IstDauerhaftErlaubbar(soul).ShouldBeFalse("Agenten veraendern wird nie dauerhaft erlaubt");
}
private sealed class Ausfuehrer : IFrozenCallExecutor
{
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
=> Task.FromResult("1 Zeilen betroffen.");
}
}
@@ -226,6 +226,40 @@ public sealed class TaskScannerTests : IDisposable
TaskItemStatus.Blocked, "nicht alle Blocker sind erledigt");
}
// ─── #643: Nicht gelaufene Aufgaben ───
[Fact]
public async Task Eine_einmalige_Aufgabe_ohne_Budget_bleibt_faellig_statt_liegenzubleiben()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-einmal", Title = "einmal", Assignee = "@new:a", Status = TaskItemStatus.Todo }, default);
var dispatcher = new FakeDispatcher { Ergebnis = TaskLauf.NichtGelaufen };
var scanner = Scanner(dispatcher);
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
var task = (await _repo.GetAsync("t-einmal", default))!;
task.Status.ShouldBe(TaskItemStatus.Todo);
task.LastOccurrence.ShouldBeNull("der Termin gilt als nicht verbraucht");
(await scanner.ScanOnceAsync(default)).ShouldBe(0, "Wiedervorlage erst nach der Pause, nicht im Minutentakt");
_clock.Advance(TaskScanner.Wiedervorlage + TimeSpan.FromMinutes(1));
dispatcher.Ergebnis = TaskLauf.Erledigt;
(await scanner.ScanOnceAsync(default)).ShouldBe(1);
(await _repo.GetAsync("t-einmal", default))!.Status.ShouldBe(TaskItemStatus.Done);
}
[Fact]
public async Task Eine_gescheiterte_einmalige_Aufgabe_bleibt_verbraucht()
{
await _repo.UpsertAsync(new TaskItem { Id = "t-kaputt", Title = "kaputt", Assignee = "@new:a", Status = TaskItemStatus.Todo }, default);
var scanner = Scanner(new FakeDispatcher(succeed: false));
await scanner.ScanOnceAsync(default);
(await _repo.GetAsync("t-kaputt", default))!.LastOccurrence.ShouldNotBeNull("kein Retry-Sturm nach echtem Fehlschlag");
}
// ─── Attrappen ───
private sealed class FakeDispatcher(bool succeed = true) : ITaskDispatcher
@@ -233,11 +267,13 @@ public sealed class TaskScannerTests : IDisposable
private readonly Lock _lock = new();
public List<string> Dispatched { get; } = [];
public Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
public TaskLauf? Ergebnis { get; set; }
public Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
lock (_lock)
Dispatched.Add(task.Id);
return Task.FromResult(succeed);
return Task.FromResult(Ergebnis ?? (succeed ? TaskLauf.Erledigt : TaskLauf.Gescheitert));
}
}
@@ -38,7 +38,7 @@ public sealed class ToolJobDispatchTests
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
ok.ShouldBeTrue("ein Poll ohne Fund ist kein Fehler");
ok.ShouldBe(TaskLauf.Erledigt, "ein Poll ohne Fund ist kein Fehler");
tool.Ticks.ShouldBe(1);
fixture.Client.ReceivedRequests.ShouldBeEmpty("kein Agent-Lauf, wenn nichts zu wecken war");
}
@@ -53,7 +53,7 @@ public sealed class ToolJobDispatchTests
var ok = await Dispatcher(fixture).DispatchAsync(PollTask(), default);
ok.ShouldBeTrue();
ok.ShouldBe(TaskLauf.Erledigt);
tool.Ticks.ShouldBe(1);
fixture.Client.ReceivedRequests.ShouldNotBeEmpty("der Agent wurde geweckt");
}
@@ -64,7 +64,7 @@ public sealed class ToolJobDispatchTests
var fixture = new EngineFixture(); // PollTool nicht registriert
fixture.AddAgent("agent-a");
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBeFalse();
(await Dispatcher(fixture).DispatchAsync(PollTask(), default)).ShouldBe(TaskLauf.Gescheitert);
}
/// <summary>Ein Tool, das zugleich Poll-Provider ist — zählt Ticks, liefert ein festes Ergebnis.</summary>
@@ -1,4 +1,5 @@
using System.Text.Json;
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tools;
using ClawdDotNet.Tools.Database;
using ClawdDotNet.Tools.Tests.FileRW;
@@ -8,8 +9,8 @@ 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.
/// Vorpruefung (#579), Freigabeschluessel (#609) und Rechte (#641) des Database-Werkzeugs -
/// alles ohne Datenbankverbindung pruefbar, weil es nur Konfiguration und Statement ansieht.
/// </summary>
public sealed class DatabaseVorpruefungTests
{
@@ -29,39 +30,70 @@ public sealed class DatabaseVorpruefungTests
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");
[Fact]
public void Kommentare_werden_vorab_abgelehnt()
=> Tool.Preflight(Sql("DELETE FROM messwerte -- messwerte"), Kontext("Admin", "messwerte"))
.ShouldNotBeNull();
[Theory]
[InlineData("SELECT * FROM messwerte", "ReadOnly")]
[InlineData("INSERT INTO messwerte (ort) VALUES ('Berlin')", "ReadWrite")]
[InlineData("INSERT INTO messwerte (ort) VALUES ('Berlin')", "ReadOnly")]
[InlineData("ALTER TABLE messwerte ADD COLUMN x INT", "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();
public void Die_Vorpruefung_urteilt_nicht_mehr_ueber_die_Operation(string sql, string level)
{
// #641: Ob ein INSERT eines lesenden Agenten laeuft, vorgelegt oder verboten wird,
// entscheidet die Freigabestelle - nicht mehr eine stille Ablehnung im Werkzeug.
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)
[InlineData("SELECT * FROM messwerte", "query", "select")]
[InlineData("DESCRIBE messwerte", "query", "select")]
[InlineData("SHOW COLUMNS FROM messwerte", "query", "select")]
[InlineData("INSERT INTO messwerte (ort) VALUES ('Berlin')", "query", "insert")]
[InlineData("UPDATE messwerte SET ort = 'X'", "insert", "update")]
[InlineData("DELETE FROM messwerte WHERE id = 1", "query", "delete")]
[InlineData("CREATE TABLE messwerte (id INT)", "query", "create")]
[InlineData("ALTER TABLE messwerte ADD COLUMN x INT", "query", "alter")]
[InlineData("DROP TABLE IF EXISTS messwerte", "query", "drop")]
[InlineData("insert into messwerte (ort) values ('select')", "query", "insert")]
public void Der_Freigabeschluessel_ist_die_SQL_Operation_nicht_das_action_Argument(string sql, string action, string erwartet)
=> Tool.StagingAktion(Sql(sql, action)).ShouldBe(erwartet);
[Theory]
[InlineData("query", "select")]
[InlineData("insert", "insert")]
[InlineData("upsert", "update")]
public void MongoDB_Aktionen_werden_auf_dieselben_Operationen_abgebildet(string action, string erwartet)
=> Tool.StagingAktion(JsonSerializer.SerializeToElement(new { action, collection = "c" })).ShouldBe(erwartet);
[Fact]
public void Ohne_sql_gilt_das_action_Argument_wie_bei_MongoDB()
=> Tool.StagingAktion(JsonSerializer.SerializeToElement(new { action = "insert", collection = "c" })).ShouldBeNull();
public void Die_Operationen_sind_fuer_die_Oberflaeche_aufgezaehlt()
=> Tool.Operationen.ShouldBe(["select", "insert", "update", "delete", "replace", "create", "alter", "drop", "truncate"]);
[Theory]
[InlineData("select", StagingDecision.Auto)]
[InlineData("insert", StagingDecision.Approve)]
[InlineData("alter", StagingDecision.Approve)]
public void Ohne_erlaubt_liest_der_Agent_frei_und_legt_alles_andere_vor(string operation, StagingDecision erwartet)
=> Tool.Vorgabe(operation, new Dictionary<string, object?>()).ShouldBe(erwartet);
[Fact]
public async Task Ohne_Freigabestelle_bleibt_die_Zugriffsstufe_eine_harte_Grenze()
{
// Kein Gate, keine Pruefung: dann muss das Werkzeug selbst verweigern, bevor es
// ueberhaupt eine Verbindung aufbaut.
var ergebnis = await Tool.ExecuteAsync(
Sql("INSERT INTO messwerte (ort) VALUES ('Berlin')"), Kontext("ReadOnly", "messwerte"), default);
ergebnis.Success.ShouldBeFalse();
ergebnis.ErrorMessage.ShouldNotBeNull().ShouldContain("ReadOnly");
}
}