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
+6 -3
View File
@@ -522,7 +522,8 @@ public sealed class AppHost : IAsyncDisposable
var taskRepository = new SqliteTaskRepository(storage);
var audit = new SqliteAuditRepository(storage);
var stagingRepository = new SqliteStagingRepository(storage);
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository);
var dauerfreigaben = new SqliteDauerfreigabeRepository(storage);
var stagingGate = new StagingGate(new StagingPolicy(), stagingRepository, dauerfreigaben: dauerfreigaben);
var usage = new SqliteUsageRepository(storage);
// Preise fürs Budget: Ohne sie greift nur die Token-Grenze.
@@ -555,7 +556,7 @@ public sealed class AppHost : IAsyncDisposable
var (scanner, staging, board) = BuildTaskboard(
instance, taskRepository, engine, tools, stateStore, audit,
stagingRepository, loggerFactory, logger);
stagingRepository, dauerfreigaben, loggerFactory, logger);
var host = new AppHost
{
@@ -681,6 +682,7 @@ public sealed class AppHost : IAsyncDisposable
SqliteStateStore stateStore,
SqliteAuditRepository audit,
SqliteStagingRepository stagingRepository,
SqliteDauerfreigabeRepository dauerfreigaben,
ILoggerFactory loggerFactory,
ILogger logger)
{
@@ -693,7 +695,8 @@ public sealed class AppHost : IAsyncDisposable
var board = new TaskboardService(taskRepository, Path.Combine(sharedWorkspace, "tasks"));
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit);
var staging = new StagingService(stagingRepository, engine, board, loggerFactory, audit,
dauerfreigaben: dauerfreigaben);
var dispatcher = new EngineTaskDispatcher(
engine, () => instance.Agents, instance.InstanceId, tools, stateStore, loggerFactory);
@@ -1,5 +1,6 @@
using ClawdDotNet.Core.Config;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Staging;
namespace ClawdDotNet.App.Services;
@@ -202,12 +203,62 @@ public sealed class AgentAdministration(AppHost host)
/// <para>Einstellungen zu schreiben heißt, das Werkzeug zu wollen: Es wird dabei
/// zugelassen, falls es das noch nicht war.</para>
/// </summary>
/// <summary>
/// Schreibt die Rechte eines Werkzeugs (#641): was ohne Rueckfrage laeuft
/// (<paramref name="erlaubt"/>) und was gar nicht erst vorgelegt wird
/// (<paramref name="verboten"/>). Alles andere wird einzeln zur Freigabe vorgelegt.
/// <paramref name="erlaubt"/> <c>null</c> entfernt die Liste - dann gilt wieder die
/// Vorgabe des Werkzeugs.
/// </summary>
public AdminResult SetToolRights(
string agentId, string toolName, IReadOnlyCollection<string>? erlaubt, IReadOnlyCollection<string> verboten)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
if (!agent.Tools.TryGetValue(toolName, out var settings))
return AdminResult.Failure($"Werkzeug '{toolName}' ist '{agent.DisplayName}' nicht zugewiesen.");
static string[] Liste(IEnumerable<string> werte) => werte
.Where(w => !string.IsNullOrWhiteSpace(w))
.Select(Werkzeugrechte.Normalisiere)
.Distinct()
.Order()
.ToArray();
var verbotenListe = Liste(verboten);
var erlaubtListe = erlaubt is null ? null : Liste(erlaubt).Except(verbotenListe).ToArray();
if (erlaubtListe is null) settings.Remove(Werkzeugrechte.ErlaubtSchluessel);
else settings[Werkzeugrechte.ErlaubtSchluessel] = erlaubtListe;
if (verbotenListe.Length == 0) settings.Remove(Werkzeugrechte.VerbotenSchluessel);
else settings[Werkzeugrechte.VerbotenSchluessel] = verbotenListe;
PersistAgent(agent);
host.SaveInstance(new InstanceChange(InstanceChangeKind.Agents, agentId));
return AdminResult.Success($"Rechte für '{toolName}' ({agent.DisplayName}) gespeichert.");
}
public AdminResult SetToolSettings(
string agentId, string toolName, Dictionary<string, object?> settings)
{
if (Find(agentId) is not { } agent)
return AdminResult.Failure($"Kein Agent mit der ID '{agentId}'.");
// #641: Die Einstellungsmasken kennen die Rechte nicht und schreiben ein frisches
// Woerterbuch. Ohne diese Uebernahme loeschte jedes Speichern still "erlaubt" und
// "verboten" - und damit die Rechte des Agenten.
if (agent.Tools.TryGetValue(toolName, out var bisher))
{
foreach (var schluessel in new[] { Werkzeugrechte.ErlaubtSchluessel, Werkzeugrechte.VerbotenSchluessel })
{
if (!settings.ContainsKey(schluessel) && bisher.TryGetValue(schluessel, out var wert))
settings[schluessel] = wert;
}
}
agent.Tools[toolName] = settings;
PersistAgent(agent);
+9 -2
View File
@@ -1392,9 +1392,13 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
// ausgeführt. Eine Prompt-Injection kann so nur einen Vorschlag erzeugen.
if (_stagingGate is not null)
{
// #641: Die Rechte stehen in der Werkzeugkonfiguration des Agenten.
var operation = tool.StagingAktion(input) ?? Staging.StagingGate.ExtractAction(arguments);
var intercept = await _stagingGate.InterceptAsync(
agentConfig.AgentId, instanceId, runId, toolName, arguments, ct,
tool.StagingAktion(input));
operation,
Staging.Werkzeugrechte.Aus(context.ToolConfig),
operation is null ? null : tool.Vorgabe(operation, context.ToolConfig));
if (intercept.Outcome == Staging.StagingOutcome.Denied)
{
@@ -1407,6 +1411,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
await Audit(AuditStatus.Staged, intercept.Message);
return intercept.Message; // dem Agenten als reguläres Tool-Ergebnis
}
context = context with { FreigabeGeprueft = true };
}
logger.LogDebug("Executing tool {Tool} for agent {AgentId}", toolName, agentConfig.AgentId);
@@ -1576,7 +1582,8 @@ public sealed class AgentEngine : IAgentMessageRouter, Staging.IFrozenCallExecut
? default
: JsonDocument.Parse(argumentsJson).RootElement;
var context = BuildToolContext(config, _instanceId, tool, ct);
// #641: Ein Mensch hat genau diesen Aufruf freigegeben.
var context = BuildToolContext(config, _instanceId, tool, ct) with { FreigabeGeprueft = true };
var result = await agentTool.ExecuteAsync(input, context, ct);
var status = result.Success ? AuditStatus.Ok : AuditStatus.Error;
@@ -0,0 +1,131 @@
using System.Globalization;
using ClawdDotNet.Core.Storage;
using Microsoft.Data.Sqlite;
namespace ClawdDotNet.Core.Staging;
/// <summary>
/// „Gleichartiges künftig erlauben" (#641): Ein Mensch hat eine Operation eines Agenten
/// freigegeben und dabei entschieden, dass dieselbe Operation künftig ohne Rückfrage laufen
/// darf. Gilt je Agent, Werkzeug und Operation — nicht für andere Agenten, nicht für
/// andere Operationen. Widerrufbar; ein Widerruf löscht nichts, er beendet die Geltung.
/// </summary>
public sealed record Dauerfreigabe
{
public long Id { get; init; }
public string AgentId { get; init; } = "";
public string Tool { get; init; } = "";
public string Operation { get; init; } = "";
public string ErteiltVon { get; init; } = "";
public DateTime ErteiltAm { get; init; }
/// <summary>Der Vorschlag, bei dessen Freigabe sie erteilt wurde.</summary>
public long? AnlassId { get; init; }
public DateTime? WiderrufenAm { get; init; }
public string? WiderrufenVon { get; init; }
public bool Gilt => WiderrufenAm is null;
}
public interface IDauerfreigabeRepository
{
/// <summary>Erteilt sie. Gilt schon eine gleiche, bleibt es bei dieser — keine Dublette.</summary>
Task<Dauerfreigabe> ErteilenAsync(Dauerfreigabe freigabe, CancellationToken ct);
/// <summary>Die geltende Dauerfreigabe für genau diese Operation, sonst <c>null</c>.</summary>
Task<Dauerfreigabe?> FindeAsync(string agentId, string tool, string operation, CancellationToken ct);
Task<IReadOnlyList<Dauerfreigabe>> ListAsync(bool mitWiderrufenen, CancellationToken ct);
Task<bool> WiderrufenAsync(long id, string von, DateTime jetzt, CancellationToken ct);
}
public sealed class SqliteDauerfreigabeRepository(SqliteStorage storage) : IDauerfreigabeRepository
{
private const string Spalten =
"Id, AgentId, Tool, Operation, ErteiltVon, ErteiltAm, AnlassId, WiderrufenAm, WiderrufenVon";
public async Task<Dauerfreigabe> ErteilenAsync(Dauerfreigabe freigabe, CancellationToken ct)
{
var op = Werkzeugrechte.Normalisiere(freigabe.Operation);
if (await FindeAsync(freigabe.AgentId, freigabe.Tool, op, ct) is { } vorhanden)
return vorhanden;
var id = await storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO Dauerfreigaben (AgentId, Tool, Operation, ErteiltVon, ErteiltAm, AnlassId)
VALUES (@agent, @tool, @op, @von, @am, @anlass);
SELECT last_insert_rowid();
""";
cmd.Parameters.AddWithValue("@agent", freigabe.AgentId);
cmd.Parameters.AddWithValue("@tool", freigabe.Tool);
cmd.Parameters.AddWithValue("@op", op);
cmd.Parameters.AddWithValue("@von", freigabe.ErteiltVon);
cmd.Parameters.AddWithValue("@am", Format(freigabe.ErteiltAm == default ? DateTime.UtcNow : freigabe.ErteiltAm));
cmd.Parameters.AddWithValue("@anlass", (object?)freigabe.AnlassId ?? DBNull.Value);
return Convert.ToInt64(await cmd.ExecuteScalarAsync(ct));
}, ct);
return (await LiesAsync("WHERE Id = @id", ct, ("@id", id))).Single();
}
public async Task<Dauerfreigabe?> FindeAsync(string agentId, string tool, string operation, CancellationToken ct)
=> (await LiesAsync(
"WHERE AgentId = @agent AND Tool = @tool COLLATE NOCASE AND Operation = @op AND WiderrufenAm IS NULL LIMIT 1",
ct, ("@agent", agentId), ("@tool", tool), ("@op", Werkzeugrechte.Normalisiere(operation))))
.FirstOrDefault();
public Task<IReadOnlyList<Dauerfreigabe>> ListAsync(bool mitWiderrufenen, CancellationToken ct)
=> LiesAsync(mitWiderrufenen ? "ORDER BY Id DESC" : "WHERE WiderrufenAm IS NULL ORDER BY Id DESC", ct);
public Task<bool> WiderrufenAsync(long id, string von, DateTime jetzt, CancellationToken ct)
=> storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Dauerfreigaben SET WiderrufenAm = @am, WiderrufenVon = @von
WHERE Id = @id AND WiderrufenAm IS NULL
""";
cmd.Parameters.AddWithValue("@am", Format(jetzt));
cmd.Parameters.AddWithValue("@von", von);
cmd.Parameters.AddWithValue("@id", id);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
private async Task<IReadOnlyList<Dauerfreigabe>> LiesAsync(
string bedingung, CancellationToken ct, params (string Name, object Wert)[] parameter)
{
await using var conn = await storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = $"SELECT {Spalten} FROM Dauerfreigaben {bedingung}";
foreach (var (name, wert) in parameter)
cmd.Parameters.AddWithValue(name, wert);
var liste = new List<Dauerfreigabe>();
await using var reader = await cmd.ExecuteReaderAsync(ct);
while (await reader.ReadAsync(ct))
liste.Add(Lies(reader));
return liste;
}
private static Dauerfreigabe Lies(SqliteDataReader r) => new()
{
Id = r.GetInt64(0),
AgentId = r.GetString(1),
Tool = r.GetString(2),
Operation = r.GetString(3),
ErteiltVon = r.GetString(4),
ErteiltAm = Parse(r.GetString(5)),
AnlassId = r.IsDBNull(6) ? null : r.GetInt64(6),
WiderrufenAm = r.IsDBNull(7) ? null : Parse(r.GetString(7)),
WiderrufenVon = r.IsDBNull(8) ? null : r.GetString(8)
};
private static string Format(DateTime value) => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture);
private static DateTime Parse(string value)
=> DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind).ToUniversalTime();
}
@@ -122,6 +122,22 @@ public sealed class SqliteStagingRepository : IStagingRepository
return results;
}
public async Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct)
{
await using var conn = await _storage.OpenConnectionAsync(ct);
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT COUNT(*) FROM StagedCalls
WHERE Status = 'Executed' AND AgentId = @agentId AND Tool = @tool
AND COALESCE(Action, '') = @action COLLATE NOCASE AND DecidedAt >= @since
""";
cmd.Parameters.AddWithValue("@agentId", agentId);
cmd.Parameters.AddWithValue("@tool", tool);
cmd.Parameters.AddWithValue("@action", action ?? "");
cmd.Parameters.AddWithValue("@since", Format(since));
return Convert.ToInt32(await cmd.ExecuteScalarAsync(ct));
}
public Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
+29 -8
View File
@@ -37,13 +37,17 @@ public sealed class StagingGate
private readonly StagingPolicy _policy;
private readonly IStagingRepository _repo;
private readonly IDauerfreigabeRepository? _dauer;
private readonly TimeSpan _verfall;
private readonly TimeProvider _clock;
public StagingGate(StagingPolicy policy, IStagingRepository repo, TimeSpan? verfall = null, TimeProvider? clock = null)
public StagingGate(
StagingPolicy policy, IStagingRepository repo, TimeSpan? verfall = null, TimeProvider? clock = null,
IDauerfreigabeRepository? dauerfreigaben = null)
{
_policy = policy;
_repo = repo;
_dauer = dauerfreigaben;
_verfall = verfall ?? StandardVerfall;
_clock = clock ?? TimeProvider.System;
}
@@ -60,13 +64,30 @@ public sealed class StagingGate
/// (IAgentTool.StagingAktion). Ohne ihn gilt das <c>action</c>-Argument - das aber waehlt
/// das Modell frei (#609).
/// </param>
/// <param name="rechte">#641: <c>erlaubt</c>/<c>verboten</c> aus der Werkzeugkonfiguration des Agenten.</param>
/// <param name="vorgabe">#641: Die Einstufung, die das Werkzeug selbst vorgibt.</param>
public async Task<StagingInterception> InterceptAsync(
string agentId, string instanceId, string runId,
string tool, string argumentsJson, CancellationToken ct, string? action = null)
string tool, string argumentsJson, CancellationToken ct, string? action = null,
Werkzeugrechte? rechte = null, StagingDecision? vorgabe = null)
{
action ??= ExtractAction(argumentsJson);
rechte ??= Werkzeugrechte.Keine;
switch (_policy.Decide(tool, action))
var einstufung = FreigabeRegeln.Einstufen(tool, action, rechte, vorgabe, _policy);
// Eine Dauerfreigabe hebt nur auf, was sonst vorgelegt wuerde - nie ein Verbot und
// nie eine Aenderung an Agenten.
if (einstufung.Entscheidung == StagingDecision.Approve
&& _dauer is not null
&& !string.IsNullOrWhiteSpace(action)
&& !FreigabeRegeln.IstImmerFreigabe(tool, action)
&& await _dauer.FindeAsync(agentId, tool, action, ct) is not null)
{
einstufung = FreigabeRegeln.Einstufen(tool, action, rechte, vorgabe, _policy, dauerfreigabe: true);
}
switch (einstufung.Entscheidung)
{
case StagingDecision.Auto:
return StagingInterception.Proceed;
@@ -74,7 +95,7 @@ public sealed class StagingGate
case StagingDecision.Deny:
return new StagingInterception(
StagingOutcome.Denied,
$"Aktion '{Label(tool, action)}' ist gesperrt (Policy: deny) und wurde nicht ausgeführt.",
$"Aktion '{Label(tool, action)}' ist gesperrt und wurde nicht ausgeführt. {einstufung.Grund}",
0);
case StagingDecision.Approve:
@@ -89,7 +110,7 @@ public sealed class StagingGate
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. " +
"Lege ihn nicht noch einmal vor. " +
"Fahre mit anderer Arbeit fort oder schließe ab.",
vorhanden.Id);
}
@@ -107,9 +128,9 @@ public sealed class StagingGate
return new StagingInterception(
StagingOutcome.Staged,
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. " +
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt; du wirst danach " +
"mit dem Ergebnis geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
$"Zur Freigabe vorgelegt (#{id}): '{Label(tool, action)}'. {einstufung.Grund} " +
"Die Aktion wird erst nach menschlicher Freigabe ausgeführt. Scheitert sie oder wird sie " +
"abgelehnt, wirst du geweckt. Fahre mit anderer Arbeit fort oder schließe ab.",
id);
default:
@@ -107,4 +107,10 @@ public interface IStagingRepository
/// auf <see cref="StagingStatus.Expired"/>. Gibt die Anzahl zurueck.
/// </summary>
Task<int> ExpirePendingAsync(DateTime createdBefore, DateTime now, CancellationToken ct);
/// <summary>
/// Wie oft dieselbe Operation dieses Agenten seit <paramref name="since"/> freigegeben und
/// ausgefuehrt wurde (#641) - Grundlage fuer den Vorschlag "Gleichartiges kuenftig erlauben".
/// </summary>
Task<int> CountExecutedAsync(string agentId, string tool, string? action, DateTime since, CancellationToken ct);
}
+11 -5
View File
@@ -43,14 +43,20 @@ public sealed class StagingPolicy
{
["Mail.send"] = StagingDecision.Approve,
["Telegram.send_message"] = StagingDecision.Approve,
// #641: Der Schluessel ist die SQL-Operation (IAgentTool.StagingAktion). Database stuft
// zusaetzlich selbst ein (IAgentTool.Vorgabe: alles ausser select vorlegen); die
// Eintraege hier halten dieselbe Linie, falls ein Aufruf ohne Vorgabe ankommt.
["Database.insert"] = StagingDecision.Approve,
["Database.upsert"] = StagingDecision.Approve,
["Database.update"] = StagingDecision.Approve,
["Database.delete"] = StagingDecision.Approve,
["Database.replace"] = StagingDecision.Approve,
["Database.create"] = StagingDecision.Approve,
["Database.alter"] = StagingDecision.Approve,
["Database.drop"] = StagingDecision.Approve,
["Database.truncate"] = 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,
+174 -21
View File
@@ -17,9 +17,21 @@ public readonly record struct StagingResult(StagingResultKind Kind, string Messa
/// Die Freigabe-/Ablehnungs-Seite von A2 — die API, die die Review-Oberfläche aufruft.
///
/// Kein pausierter Lauf: Bei Freigabe wird der <b>eingefrorene</b> Aufruf direkt
/// ausgeführt (Plan-Freeze), das Ergebnis festgehalten und der Agent über einen
/// Folge-Task (A1) mit dem Ergebnis geweckt. Jede Entscheidung wird als Approval-Record
/// ins Audit-Log (A3) geschrieben.
/// ausgeführt (Plan-Freeze) und das Ergebnis festgehalten. Jede Entscheidung wird als
/// Approval-Record ins Audit-Log (A3) geschrieben.
///
/// <para><b>Wecken (#643).</b> Früher legte jede Entscheidung eine eigene Aufgabe
/// <c>@agent</c> an, die den Chatverlauf fortführte: 110 Entscheidungen, 110 Läufe, ein
/// Prompt von 58.000 Tokens und eine bis Mitternacht gesperrte Instanz. Jetzt gilt:</para>
/// <list type="bullet">
/// <item>Geweckt wird nur, wenn es etwas zu tun gibt — nach einer Ablehnung oder einer
/// gescheiterten Ausführung. Eine erfolgreich ausgeführte Freigabe weckt nicht.</item>
/// <item>Alle Entscheidungen zu einem Agenten landen in <b>einer</b> Sammelaufgabe, die
/// erst fällig wird, wenn eine Weile (<see cref="StandardBuendelFenster"/>) nichts mehr
/// dazukam.</item>
/// <item>Der Agent wird frisch geweckt (<c>@new:agent</c>): Der Vorschlag ist eingefroren,
/// den alten Verlauf braucht er dafür nicht.</item>
/// </list>
/// </summary>
public sealed class StagingService
{
@@ -28,19 +40,48 @@ public sealed class StagingService
private readonly TaskboardService _board;
private readonly IAuditRepository? _audit;
private readonly ILogger _logger;
private readonly TimeSpan _fenster;
private readonly TimeProvider _clock;
private readonly IDauerfreigabeRepository? _dauer;
/// <summary>
/// Ab so vielen gleichartigen Freigaben in <see cref="VorschlagsZeitraum"/> schlägt die
/// Oberfläche vor, sie dauerhaft zu erlauben (#641). Übernehmen muss der Mensch.
/// </summary>
public const int VorschlagAb = 3;
public static readonly TimeSpan VorschlagsZeitraum = TimeSpan.FromDays(14);
/// <summary>Ruhezeit nach der letzten Entscheidung, bevor die Sammelaufgabe fällig wird.</summary>
public static readonly TimeSpan StandardBuendelFenster = TimeSpan.FromMinutes(5);
/// <summary>Titel der Sammelaufgabe — an ihm wird eine noch offene wiedererkannt.</summary>
public const string SammelTitel = "Freigabe-Ergebnisse";
/// <summary>Ab so vielen Einträgen wird nur noch die Kurzzeile angehängt.</summary>
private const int AusfuehrlicheEintraege = 20;
/// <summary>Anlegen und Ergänzen der Sammelaufgabe dürfen sich nicht überholen.</summary>
private readonly SemaphoreSlim _sammelSperre = new(1, 1);
public StagingService(
IStagingRepository repo,
IFrozenCallExecutor executor,
TaskboardService board,
ILoggerFactory loggerFactory,
IAuditRepository? audit = null)
IAuditRepository? audit = null,
TimeSpan? buendelFenster = null,
TimeProvider? clock = null,
IDauerfreigabeRepository? dauerfreigaben = null)
{
_repo = repo;
_executor = executor;
_board = board;
_audit = audit;
_logger = loggerFactory.CreateLogger("ClawdDotNet.Core.Staging");
_fenster = buendelFenster ?? StandardBuendelFenster;
_clock = clock ?? TimeProvider.System;
_dauer = dauerfreigaben;
}
public async Task<IReadOnlyList<StagedCall>> ListPendingAsync(CancellationToken ct)
@@ -53,7 +94,44 @@ public sealed class StagingService
public Task<StagedCall?> GetAsync(long id, CancellationToken ct) => _repo.GetAsync(id, ct);
public Task<int> CountPendingAsync(CancellationToken ct) => _repo.CountPendingAsync(ct);
// ─── Dauerfreigaben (#641) ───
public bool KenntDauerfreigaben => _dauer is not null;
/// <summary>Kann für diesen Vorschlag „Gleichartiges künftig erlauben" angeboten werden?</summary>
public static bool IstDauerhaftErlaubbar(StagedCall call)
=> !string.IsNullOrWhiteSpace(call.Action) && !FreigabeRegeln.IstImmerFreigabe(call.Tool, call.Action);
/// <summary>
/// Wie oft dieselbe Operation dieses Agenten zuletzt schon freigegeben wurde — ab
/// <see cref="VorschlagAb"/> schlägt die Oberfläche die Dauerfreigabe vor.
/// </summary>
public async Task<int> BisherFreigegebenAsync(StagedCall call, CancellationToken ct)
=> IstDauerhaftErlaubbar(call)
? await _repo.CountExecutedAsync(call.AgentId, call.Tool, call.Action,
_clock.GetUtcNow().UtcDateTime - VorschlagsZeitraum, ct)
: 0;
public Task<IReadOnlyList<Dauerfreigabe>> ListDauerfreigabenAsync(bool mitWiderrufenen, CancellationToken ct)
=> _dauer?.ListAsync(mitWiderrufenen, ct) ?? Task.FromResult<IReadOnlyList<Dauerfreigabe>>([]);
public async Task<bool> WiderrufenAsync(long id, string von, CancellationToken ct)
{
if (_dauer is null || !await _dauer.WiderrufenAsync(id, von, _clock.GetUtcNow().UtcDateTime, ct))
return false;
_logger.LogInformation("Dauerfreigabe #{Id} widerrufen von {Von}", id, von);
return true;
}
public async Task<StagingResult> ApproveAsync(long id, string decidedBy, CancellationToken ct)
=> await ApproveAsync(id, decidedBy, gleichartigErlauben: false, ct);
/// <param name="gleichartigErlauben">
/// #641: Dieselbe Operation dieses Agenten künftig ohne Rückfrage zulassen. Wirkt nicht bei
/// Änderungen an Agenten — die bleiben einzeln freigabepflichtig.
/// </param>
public async Task<StagingResult> ApproveAsync(long id, string decidedBy, bool gleichartigErlauben, CancellationToken ct)
{
var call = await _repo.GetAsync(id, ct);
if (call is null) return StagingResult.NotFound;
@@ -86,18 +164,31 @@ public sealed class StagingService
await _repo.FinalizeAsync(id, final, Cap(result, 500), DateTime.UtcNow, ct);
var ausgefuehrt = final == StagingStatus.Executed;
if (gleichartigErlauben && _dauer is not null && IstDauerhaftErlaubbar(call))
{
var dauer = await _dauer.ErteilenAsync(new Dauerfreigabe
{
AgentId = call.AgentId,
Tool = call.Tool,
Operation = call.Action!,
ErteiltVon = decidedBy,
ErteiltAm = _clock.GetUtcNow().UtcDateTime,
AnlassId = call.Id
}, ct);
await RecordAuditAsync(call, decidedBy, AuditStatus.Ok,
$"Dauerfreigabe #{dauer.Id} erteilt von {decidedBy}: {Label(call)} für {call.AgentId}", ct);
}
await RecordAuditAsync(call, decidedBy, ausgefuehrt ? AuditStatus.Ok : AuditStatus.Error,
ausgefuehrt
? $"Freigegeben von {decidedBy}"
: $"Freigegeben von {decidedBy}, Ausführung gescheitert: {result}", ct);
// #193: Das Ergebnis stammt aus dem Werkzeug - gerahmt, auch wenn es als Auftrag ankommt.
await WakeAgentAsync(call,
$"Freigabe-Ergebnis: {call.Tool}",
(ausgefuehrt
? $"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben und ausgeführt.\n\n"
: $"[Freigabe] Deine vorgelegte Aktion '{Label(call)}' wurde freigegeben, die Ausführung ist aber gescheitert.\n\n") +
$"Ergebnis:\n{Engine.UntrustedContent.Rahmen(call.Tool, result)}\n\nSetze deine Arbeit fort.", ct);
// #643: Nur ein Fehlschlag gibt dem Agenten etwas zu tun. Ein Erfolg reist mit,
// wenn ohnehin eine Sammelaufgabe offen ist.
await SammelnAsync(call,
ausgefuehrt ? "freigegeben und ausgeführt" : "freigegeben, die Ausführung ist aber gescheitert",
$"Ergebnis: {result}", weckt: !ausgefuehrt, ct);
return new StagingResult(
final == StagingStatus.Executed ? StagingResultKind.Executed : StagingResultKind.Failed, result);
@@ -112,33 +203,95 @@ public sealed class StagingService
return StagingResult.AlreadyDecided;
await RecordAuditAsync(call, decidedBy, AuditStatus.Denied, $"Abgelehnt von {decidedBy}: {reason}", ct);
await WakeAgentAsync(call,
$"Freigabe abgelehnt: {call.Tool}",
$"[Ablehnung] Deine vorgelegte Aktion '{Label(call)}' wurde abgelehnt.\n\n" +
$"Grund: {reason}\n\nFühre sie nicht erneut ohne Rücksprache aus.", ct);
await SammelnAsync(call, "abgelehnt", $"Grund: {reason}", weckt: true, ct);
return new StagingResult(StagingResultKind.Rejected, reason);
}
// ─── Hilfsfunktionen ───
private async Task WakeAgentAsync(StagedCall call, string title, string body, CancellationToken ct)
/// <summary>
/// Trägt eine Entscheidung in die offene Sammelaufgabe des Agenten ein und schiebt ihren
/// Termin um das Bündelfenster nach hinten. Gibt es keine offene, entsteht eine — aber
/// nur, wenn die Entscheidung den Agenten wecken soll.
/// </summary>
private async Task SammelnAsync(StagedCall call, string ausgang, string inhalt, bool weckt, CancellationToken ct)
{
var assignee = $"{TaskAssignee.New}:{call.AgentId}";
var termin = new TaskWhen
{
Kind = TaskWhenKind.At,
Value = (_clock.GetUtcNow().UtcDateTime + _fenster).ToString("yyyy-MM-ddTHH:mm:ssZ"),
TimeZone = "UTC"
};
await _sammelSperre.WaitAsync(ct);
try
{
var offen = (await _board.ListAsync(
new TaskQuery { Status = TaskItemStatus.Todo, Assignee = assignee, Limit = 50 }, ct))
.FirstOrDefault(t => t.Title == SammelTitel && t.LastOccurrence is null && t.ClaimToken is null);
if (offen is not null)
{
await _board.UpdateAsync(offen.Id, t => t with
{
Body = t.Body.TrimEnd() + "\n\n" + Eintrag(call, ausgang, inhalt, Anzahl(t.Body)),
When = termin
}, ct);
return;
}
if (!weckt)
return;
await _board.CreateAsync(new TaskItem
{
Title = title,
Body = body,
Title = SammelTitel,
Body = SammelKopf + "\n\n" + Eintrag(call, ausgang, inhalt, 0),
Status = TaskItemStatus.Todo,
Assignee = "@" + call.AgentId,
Assignee = assignee,
When = termin,
Type = TaskItemType.Work
}, ct);
}
catch (Exception ex)
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogError(ex, "Folge-Task für Vorschlag #{Id} konnte nicht angelegt werden", call.Id);
_logger.LogError(ex, "Sammelaufgabe für Vorschlag #{Id} konnte nicht geschrieben werden", call.Id);
}
finally
{
_sammelSperre.Release();
}
}
internal const string SammelKopf =
"[Freigabe-Ergebnisse] Ein Mensch hat über Aktionen entschieden, die du zur Freigabe "
+ "vorgelegt hattest. Freigegebene Aktionen wurden genau so ausgeführt, wie du sie vorgelegt "
+ "hast, abgelehnte gar nicht.\n"
+ "- Gescheitert: Prüfe die Ursache. Liegt sie bei dir (falscher Name, falsche Spalte, "
+ "fehlende Datei), lege die korrigierte Aktion einmal neu vor.\n"
+ "- Abgelehnt: Nicht erneut vorlegen, solange sich an der Begründung nichts geändert hat.\n"
+ "- Ausgeführt: Nichts zu tun.\n"
+ "Ist nichts zu tun, schließe sofort ab.";
private static int Anzahl(string body)
=> body.Split("\n### #").Length - 1;
/// <summary>
/// #193: Vorschlag und Ergebnis stammen aus Modell und Werkzeug - gerahmt, auch wenn sie
/// wie ein Auftrag klingen. Nach <see cref="AusfuehrlicheEintraege"/> Einträgen nur noch
/// die Kurzzeile, damit auch eine lange Entscheidungsrunde ein kleiner Lauf bleibt.
/// </summary>
private static string Eintrag(StagedCall call, string ausgang, string inhalt, int bisher)
{
var kopf = $"### #{call.Id} {Label(call)}: {ausgang}";
if (bisher >= AusfuehrlicheEintraege)
return kopf;
var vorschlag = string.IsNullOrWhiteSpace(call.Proposal) ? call.ArgumentsJson : call.Proposal;
return kopf + "\n" + Engine.UntrustedContent.Rahmen(call.Tool,
$"Vorschlag: {Cap(vorschlag, 300)}\n{Cap(inhalt, 400)}");
}
private async Task RecordAuditAsync(
@@ -0,0 +1,146 @@
using System.Collections;
using System.Text.Json;
namespace ClawdDotNet.Core.Staging;
/// <summary>
/// Was ein Agent mit einem Werkzeug darf (#641). Steht in der Werkzeugkonfiguration des
/// Agenten — dort, wo auch Verbindung, Tabellen und Verzeichnisse stehen —, nicht in einer
/// globalen Liste.
///
/// <list type="bullet">
/// <item><c>erlaubt</c>: Operationen, die ohne Rückfrage laufen. Ist die Liste gesetzt,
/// wird alles andere einzeln zur Freigabe vorgelegt — nicht stillschweigend
/// verweigert. Darf ein Agent SELECT, INSERT und UPDATE und braucht einmal ein ALTER,
/// entscheidet der Mensch über genau dieses eine ALTER.</item>
/// <item><c>verboten</c>: Operationen, die gar nicht erst vorgelegt werden. Für Agenten,
/// deren Freigabeliste sonst ein Einfallstor wäre (nur lesende Agenten, die Webinhalte
/// verarbeiten).</item>
/// </list>
///
/// <para>Ohne <c>erlaubt</c> gilt, was das Werkzeug selbst vorgibt, sonst die
/// Standardregel — das Verhalten vor #641.</para>
/// </summary>
public sealed record Werkzeugrechte(IReadOnlySet<string>? Erlaubt, IReadOnlySet<string> Verboten)
{
public const string ErlaubtSchluessel = "erlaubt";
public const string VerbotenSchluessel = "verboten";
/// <summary>Steht in <c>erlaubt</c> für „alles, was das Werkzeug kann".</summary>
public const string Alles = "*";
public static Werkzeugrechte Keine { get; } = new(null, new HashSet<string>());
public bool IstKonfiguriert => Erlaubt is not null;
public bool IstErlaubt(string operation)
=> Erlaubt is { } erlaubt && (erlaubt.Contains(Alles) || erlaubt.Contains(Normalisiere(operation)));
public bool IstVerboten(string operation) => Verboten.Contains(Normalisiere(operation));
public static Werkzeugrechte Aus(IReadOnlyDictionary<string, object?>? config)
{
if (config is null)
return Keine;
return new Werkzeugrechte(
config.TryGetValue(ErlaubtSchluessel, out var erlaubt) ? Liste(erlaubt) : null,
config.TryGetValue(VerbotenSchluessel, out var verboten) ? Liste(verboten) ?? Keine.Verboten : Keine.Verboten);
}
public static string Normalisiere(string operation) => operation.Trim().ToLowerInvariant();
/// <summary>
/// Liest eine Liste so, wie sie in der Konfiguration ankommen kann: als JSON-Array
/// (aus der Datei), als Aufzählung (aus der Oberfläche) oder als Komma-Text.
/// </summary>
internal static IReadOnlySet<string>? Liste(object? wert)
{
IEnumerable<string?>? eintraege = wert switch
{
null => null,
JsonElement { ValueKind: JsonValueKind.Array } je => je.EnumerateArray()
.Select(e => e.ValueKind == JsonValueKind.String ? e.GetString() : e.ToString()),
JsonElement { ValueKind: JsonValueKind.String } je => je.GetString()?.Split(','),
JsonElement => null,
string s => s.Split(','),
IEnumerable e => e.Cast<object?>().Select(o => o?.ToString()),
_ => null
};
return eintraege?
.Where(e => !string.IsNullOrWhiteSpace(e))
.Select(e => Normalisiere(e!))
.ToHashSet(StringComparer.Ordinal);
}
}
/// <summary>Wie ein Aufruf eingestuft wurde, und warum — der Grund geht an Agent und Mensch.</summary>
public readonly record struct Freigabeeinstufung(StagingDecision Entscheidung, string Grund);
/// <summary>
/// Die Reihenfolge der Freigabeentscheidung (#641). Rein und ohne Zustand.
///
/// <list type="number">
/// <item>Agenten verändern (Identität, Soul, neuer Agent) — immer Einzelfreigabe. Weder
/// Werkzeugkonfiguration noch Dauerfreigabe heben das auf.</item>
/// <item><c>verboten</c> in der Werkzeugkonfiguration — abgelehnt.</item>
/// <item><c>erlaubt</c> in der Werkzeugkonfiguration — läuft; alles andere wird einzeln
/// vorgelegt (außer eine Dauerfreigabe deckt es).</item>
/// <item>Dauerfreigabe („Gleichartiges künftig erlauben") — läuft.</item>
/// <item>Vorgabe des Werkzeugs, sonst die Standardregel.</item>
/// </list>
/// </summary>
public static class FreigabeRegeln
{
/// <summary>Was einen Agenten verändert. Bleibt ausnahmslos freigabepflichtig.</summary>
public static IReadOnlySet<string> ImmerFreigabe { get; } = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
"AgentEditor.update_identity",
"AgentEditor.update_soul",
"AgentEditor.create_agent"
};
public static bool IstImmerFreigabe(string tool, string? operation)
=> !string.IsNullOrWhiteSpace(operation) && ImmerFreigabe.Contains($"{tool}.{operation}");
public static Freigabeeinstufung Einstufen(
string tool, string? operation, Werkzeugrechte rechte,
StagingDecision? werkzeugVorgabe, StagingPolicy policy, bool dauerfreigabe = false)
{
if (IstImmerFreigabe(tool, operation))
return new(StagingDecision.Approve, "Änderungen an Agenten brauchen immer eine Freigabe.");
if (string.IsNullOrWhiteSpace(operation))
return Nach(policy.Decide(tool, null), "der Standardregel");
var op = Werkzeugrechte.Normalisiere(operation);
if (rechte.IstVerboten(op))
return new(StagingDecision.Deny,
$"'{op}' ist für diesen Agenten verboten (Werkzeugkonfiguration '{Werkzeugrechte.VerbotenSchluessel}').");
if (rechte.IstErlaubt(op))
return new(StagingDecision.Auto, $"'{op}' ist erlaubt (Werkzeugkonfiguration).");
if (dauerfreigabe)
return new(StagingDecision.Auto, $"'{op}' ist dauerhaft freigegeben.");
if (rechte.Erlaubt is { } erlaubt)
return new(StagingDecision.Approve,
$"'{op}' gehört nicht zu den erlaubten Operationen ({string.Join(", ", erlaubt.Order())}) " +
"und wird einzeln zur Freigabe vorgelegt.");
if (werkzeugVorgabe is { } vorgabe)
return Nach(vorgabe, "der Vorgabe des Werkzeugs");
return Nach(policy.Decide(tool, op), "der Standardregel");
}
private static Freigabeeinstufung Nach(StagingDecision entscheidung, string quelle) => new(entscheidung, entscheidung switch
{
StagingDecision.Approve => $"Nach {quelle} freigabepflichtig.",
StagingDecision.Deny => $"Nach {quelle} gesperrt.",
_ => $"Nach {quelle} erlaubt."
});
}
@@ -271,6 +271,23 @@ public sealed class SqliteStorage
CREATE INDEX IF NOT EXISTS IX_StagedCalls_Pending
ON StagedCalls (Status, Id);
-- #641: "Gleichartiges kuenftig erlauben" - je Agent, Werkzeug und Operation.
-- Ein Widerruf setzt WiderrufenAm; geloescht wird nichts.
CREATE TABLE IF NOT EXISTS Dauerfreigaben (
Id INTEGER PRIMARY KEY AUTOINCREMENT,
AgentId TEXT NOT NULL,
Tool TEXT NOT NULL,
Operation TEXT NOT NULL,
ErteiltVon TEXT NOT NULL,
ErteiltAm TEXT NOT NULL,
AnlassId INTEGER NULL,
WiderrufenAm TEXT NULL,
WiderrufenVon TEXT NULL
);
CREATE INDEX IF NOT EXISTS IX_Dauerfreigaben_Agent
ON Dauerfreigaben (AgentId, Tool, Operation);
""";
cmd.ExecuteNonQuery();
@@ -49,7 +49,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_clock = clock ?? TimeProvider.System;
}
public async Task<bool> DispatchAsync(TaskItem task, CancellationToken ct)
public async Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct)
{
// Poll-Tasks (tool_job) ticken ein Tool statt einen Agenten direkt zu starten.
if (task.Type == TaskItemType.ToolJob)
@@ -70,7 +70,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
_logger.LogWarning(
"Aufgabe {TaskId}: Assignee '{Assignee}' nicht auflösbar (frischer Lauf braucht einen eindeutigen Agenten)",
task.Id, task.Assignee);
return false;
return TaskLauf.Gescheitert;
}
result = await _engine.RunAsync(
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
@@ -84,7 +84,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
{
_logger.LogWarning(
"Aufgabe {TaskId}: Agent '{AgentId}' nicht gefunden", task.Id, agentId);
return false;
return TaskLauf.Gescheitert;
}
result = await _engine.ChatAsync(
config, message, _instanceId, ct, source: ChatSource.Task, taskId: task.Id);
@@ -92,10 +92,15 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
}
default:
// @human wird vom Scanner gar nicht erst angestoßen.
return false;
return TaskLauf.Gescheitert;
}
return result.Status == AgentRunStatus.Completed;
return result.Status switch
{
AgentRunStatus.Completed => TaskLauf.Erledigt,
AgentRunStatus.BudgetExceeded or AgentRunStatus.ShuttingDown => TaskLauf.NichtGelaufen,
_ => TaskLauf.Gescheitert
};
}
/// <summary>
@@ -103,14 +108,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
/// wenn der Tick etwas meldet. Der Tick selbst gilt als erfolgreich, auch wenn nichts
/// zu tun war — ein Poll ohne Fund ist kein Fehler.
/// </summary>
private async Task<bool> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
private async Task<TaskLauf> DispatchToolJobAsync(TaskItem task, CancellationToken ct)
{
if (_toolRegistry.Get(task.ToolName) is not IToolJobProvider provider)
{
_logger.LogWarning(
"Tool-Job {TaskId}: Tool '{Tool}' ist kein IToolJobProvider oder nicht registriert",
task.Id, task.ToolName);
return false;
return TaskLauf.Gescheitert;
}
// Zielagent (zum Wecken) und dessen Tool-Konfiguration/Workspace.
@@ -124,14 +129,14 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
task.JobTypeId, toolConfig, _stateStore, _logger, ct, agentId, config?.WorkspacePath);
if (!result.ShouldWakeAgent || string.IsNullOrWhiteSpace(result.WakeMessage))
return true; // Poll lief, nichts zu wecken
return TaskLauf.Erledigt; // Poll lief, nichts zu wecken
if (config is null)
{
_logger.LogWarning(
"Tool-Job {TaskId} wollte Agent '{AgentId}' wecken, der aber nicht gefunden wurde",
task.Id, agentId);
return true;
return TaskLauf.Erledigt;
}
// Der Provider entscheidet je Tick, ob mit Kontext (ChatAsync) oder zustandslos.
@@ -140,7 +145,7 @@ public sealed class EngineTaskDispatcher : ITaskDispatcher
else
await _engine.RunAsync(config, result.WakeMessage, _instanceId, ct, source: ChatSource.Job, taskId: task.Id);
return true;
return TaskLauf.Erledigt;
}
private static AgentConfig? ResolveFreshAgent(IReadOnlyList<AgentConfig> agents, string agentId)
@@ -44,6 +44,14 @@ public interface ITaskRepository
string id, string claimToken, TaskItemStatus finalStatus,
DateTime now, CancellationToken ct);
/// <summary>
/// Gibt einen Claim zurück, als hätte es ihn nicht gegeben: Status <c>todo</c> und der
/// Marker auf <paramref name="previousOccurrence"/> (#643). Für Läufe, die gar nicht
/// stattfanden. Nur wirksam, solange der Aufrufer den Claim hält.
/// </summary>
Task<bool> ReleaseClaimAsync(
string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct);
/// <summary>Ändert den Status direkt (Tool-Aktionen, Auto-Dispatch, Eskalation).</summary>
Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct);
@@ -162,6 +162,25 @@ public sealed class SqliteTaskRepository : ITaskRepository
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> ReleaseClaimAsync(
string id, string claimToken, string? previousOccurrence, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE Tasks
SET Status = 'todo', ClaimToken = NULL, ClaimedAt = NULL,
LastOccurrence = @occ, UpdatedAt = @now
WHERE Id = @id AND ClaimToken = @token
""";
cmd.Parameters.AddWithValue("@occ", (object?)previousOccurrence ?? DBNull.Value);
cmd.Parameters.AddWithValue("@now", Format(now));
cmd.Parameters.AddWithValue("@id", id);
cmd.Parameters.AddWithValue("@token", claimToken);
return await cmd.ExecuteNonQueryAsync(ct) > 0;
}, ct);
public Task<bool> SetStatusAsync(string id, TaskItemStatus status, DateTime now, CancellationToken ct)
=> _storage.WriteAsync(async conn =>
{
+51 -5
View File
@@ -2,14 +2,30 @@ using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Core.Tasks;
/// <summary>Wie ein Dispatch ausging.</summary>
public enum TaskLauf
{
/// <summary>Der Lauf schloss regulär ab.</summary>
Erledigt,
/// <summary>Der Lauf fand statt und scheiterte.</summary>
Gescheitert,
/// <summary>
/// Der Lauf fand gar nicht statt — Budget erschöpft, Instanz im Herunterfahren (#643).
/// Eine einmalige Aufgabe bleibt dann fällig, statt als gelaufen liegenzubleiben.
/// </summary>
NichtGelaufen
}
/// <summary>
/// Führt eine fällige Aufgabe aus. Kapselt, wie ein Assignee zu einem Lauf wird — der
/// Scanner selbst kennt die Engine nicht und bleibt so ohne sie testbar.
/// </summary>
public interface ITaskDispatcher
{
/// <summary>Führt die Aufgabe aus. Gibt zurück, ob der Lauf regulär abschloss.</summary>
Task<bool> DispatchAsync(TaskItem task, CancellationToken ct);
/// <summary>Führt die Aufgabe aus und meldet, wie der Lauf ausging.</summary>
Task<TaskLauf> DispatchAsync(TaskItem task, CancellationToken ct);
}
/// <summary>
@@ -58,6 +74,16 @@ public sealed class TaskScanner : IAsyncDisposable
/// <summary>Wiederkehrende @agent-Aufgaben, deren wachsender Kontext bereits gemeldet wurde (#598).</summary>
private readonly HashSet<string> _warnedContext = [];
/// <summary>
/// #643: Einmalige Aufgaben, deren Lauf nicht stattfand, und ab wann sie wieder dran sind.
/// Ohne diese Pause würde eine wegen Budget abgewiesene Aufgabe jede Minute erneut
/// anlaufen. Nur im Speicher — nach einem Neustart darf sie einmal sofort versuchen.
/// </summary>
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DateTime> _zurueckgestellt = new();
/// <summary>Pause, bevor eine nicht gelaufene einmalige Aufgabe erneut versucht wird.</summary>
public static readonly TimeSpan Wiedervorlage = TimeSpan.FromMinutes(30);
private readonly CancellationTokenSource _cts = new();
private Task? _loop;
@@ -196,6 +222,9 @@ public sealed class TaskScanner : IAsyncDisposable
_logger.LogWarning("Aufgabe {TaskId} ({Title}): {Hinweis}", task.Id, task.Title, hinweis);
}
if (_zurueckgestellt.TryGetValue(task.Id, out var ab) && now < ab)
continue;
var occ = TaskSchedule.DueOccurrence(task, now);
if (occ is null)
continue;
@@ -257,10 +286,10 @@ public sealed class TaskScanner : IAsyncDisposable
private async Task ProcessCoreAsync(TaskItem task, string token, CancellationToken ct)
{
bool ok;
TaskLauf lauf;
try
{
ok = await _dispatcher.DispatchAsync(task, ct);
lauf = await _dispatcher.DispatchAsync(task, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
@@ -269,10 +298,27 @@ public sealed class TaskScanner : IAsyncDisposable
catch (Exception ex)
{
_logger.LogError(ex, "Dispatch für Aufgabe {TaskId} ist gescheitert", task.Id);
ok = false;
lauf = TaskLauf.Gescheitert;
}
var now = _clock.GetUtcNow().UtcDateTime;
var ok = lauf == TaskLauf.Erledigt;
// #643: Fand der Lauf einer einmaligen Aufgabe gar nicht statt, gilt ihr Termin als
// nicht verbraucht. Vorher blieb sie mit gesetztem Marker für immer auf todo liegen.
if (lauf == TaskLauf.NichtGelaufen && !task.IsRecurring)
{
if (await _repo.ReleaseClaimAsync(task.Id, token, task.LastOccurrence, now, ct))
{
_zurueckgestellt[task.Id] = now + Wiedervorlage;
_logger.LogInformation(
"Aufgabe {TaskId} lief nicht (Budget/Herunterfahren) — erneut fällig ab {Ab:HH:mm} UTC",
task.Id, now + Wiedervorlage);
}
return;
}
_zurueckgestellt.TryRemove(task.Id, out _);
// Endstatus:
// - Fehlschlag → zurück auf todo (der Marker steht schon beim Claim, derselbe Termin
+11 -1
View File
@@ -24,4 +24,14 @@ public sealed record AgentToolContext(
/// nicht von einer neuen Delegation unterscheiden. Siehe <see cref="DelegationChain"/>.
/// </summary>
DelegationChain? Delegation = null
);
)
{
/// <summary>
/// Die Freigabestelle hat diesen Aufruf geprueft und zugelassen - als erlaubte Operation,
/// per Dauerfreigabe oder als einzeln freigegebener Vorschlag (#641). Ein Werkzeug darf
/// seine eigene Rechtepruefung dann auslassen; sonst wuerde ein freigegebenes ALTER eines
/// Agenten, der nur schreiben darf, erst nach der Freigabe am Werkzeug scheitern.
/// Ohne Freigabestelle bleibt es <c>false</c>, und das Werkzeug prueft selbst.
/// </summary>
public bool FreigabeGeprueft { get; init; }
}
+13
View File
@@ -65,6 +65,19 @@ public interface IAgentTool
/// </summary>
string? StagingAktion(JsonElement input) => null;
/// <summary>
/// Die Operationen, die sich in der Werkzeugkonfiguration eines Agenten erlauben oder
/// verbieten lassen (#641) - dieselben Schluessel, die <see cref="StagingAktion"/> bzw. das
/// <c>action</c>-Argument liefern. Vorgabe: die Werte von <c>action</c> aus dem Schema.
/// </summary>
IReadOnlyList<string> Operationen => WerkzeugOperationen.AusSchema(InputSchema);
/// <summary>
/// Die Einstufung einer Operation, wenn die Werkzeugkonfiguration des Agenten kein
/// <c>erlaubt</c> setzt (#641). <c>null</c> = die Standardregel gilt.
/// </summary>
Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary<string, object?> config) => null;
Task<ToolResult> ExecuteAsync(
JsonElement input,
AgentToolContext context,
@@ -0,0 +1,27 @@
using System.Text.Json;
namespace ClawdDotNet.Core.Tools;
/// <summary>Hilfen rund um die Operationen eines Werkzeugs (#641).</summary>
public static class WerkzeugOperationen
{
/// <summary>Die Werte der <c>action</c>-Aufzaehlung im Eingabeschema, sonst keine.</summary>
public static IReadOnlyList<string> AusSchema(JsonElement schema)
{
if (schema.ValueKind != JsonValueKind.Object
|| !schema.TryGetProperty("properties", out var props)
|| props.ValueKind != JsonValueKind.Object
|| !props.TryGetProperty("action", out var action)
|| action.ValueKind != JsonValueKind.Object
|| !action.TryGetProperty("enum", out var werte)
|| werte.ValueKind != JsonValueKind.Array)
{
return [];
}
return werte.EnumerateArray()
.Where(w => w.ValueKind == JsonValueKind.String)
.Select(w => w.GetString()!)
.ToList();
}
}
@@ -1375,3 +1375,43 @@ button.klein {
color: var(--text);
}
/* ─── Hinweiskaesten (#608, #641) ─── */
.hinweis-box {
border: 1px solid var(--divider);
border-left-width: 4px;
background: var(--surface);
padding: 10px 14px;
margin: 10px 0;
font-size: 14px;
}
.hinweis-box.warnung {
border-color: var(--warnung);
background: rgba(181, 130, 42, 0.10);
}
.hinweis-box.vorschlag {
border-color: var(--erfolg);
background: rgba(63, 143, 92, 0.10);
}
/* ─── Rechte je Werkzeug (#641) ─── */
.rechte-tabelle select {
min-width: 150px;
}
.rechte-tabelle .grund {
font-size: 12px;
color: var(--gedaempft);
}
.gleichartig-wahl {
display: flex;
align-items: center;
gap: 8px;
margin-top: 10px;
font-size: 14px;
}
@@ -411,19 +411,129 @@ async function zeigeWerkzeugEinstellungen(agentId, toolName) {
box.append(
erstelleElement("p", { klasse: "gedaempft", text: "Keine individuellen Einstellungen hinterlegt." })
);
return;
} else {
const dl = erstelleElement("dl", { klasse: "datenliste" });
for (const [schluessel, wert] of Object.entries(daten)) {
const dt = erstelleElement("dt", { text: schluessel });
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
dl.append(dt, dd);
}
box.append(dl);
}
const dl = erstelleElement("dl", { klasse: "datenliste" });
for (const [schluessel, wert] of Object.entries(daten)) {
const dt = erstelleElement("dt", { text: schluessel });
const dd = erstelleElement("dd", { text: typeof wert === "object" ? JSON.stringify(wert) : String(wert) });
dl.append(dt, dd);
}
box.append(dl);
} catch (err) {
box.replaceChildren(
erstelleElement("p", { klasse: "fehler", text: `Laden fehlgeschlagen: ${err.message}` })
);
}
const rechteBox = erstelleElement("div", { klasse: "einstellungen-box" });
box.after(rechteBox);
await zeigeWerkzeugrechte(agentId, toolName, rechteBox);
}
const WIRKUNG = {
auto: ["läuft", "erfolg"],
approve: ["einzeln freigeben", "warnung"],
deny: ["gesperrt", "gefahr"]
};
/**
* #641: Rechte je Operation. "erlaubt" laeuft ohne Rueckfrage, "freigabe" wird einzeln
* vorgelegt, "verboten" gar nicht erst. Die Wirkung zeigt, was tatsaechlich geschieht -
* auch durch Dauerfreigaben oder die Vorgabe des Werkzeugs.
*/
async function zeigeWerkzeugrechte(agentId, toolName, box) {
const pfad = `/agents/${encodeURIComponent(agentId)}/tools/${encodeURIComponent(toolName)}/rights`;
let rechte;
try {
rechte = await api(pfad);
} catch (err) {
box.replaceChildren(erstelleElement("p", { klasse: "gedaempft", text: `Rechte nicht lesbar: ${err.message}` }));
return;
}
box.replaceChildren(erstelleElement("h3", { text: "Rechte" }));
if (!rechte.operations || rechte.operations.length === 0) {
box.append(
erstelleElement("p", {
klasse: "gedaempft",
text: "Dieses Werkzeug kennt keine einzelnen Operationen. Es gilt die Standardregel."
})
);
return;
}
box.append(
erstelleElement("p", {
klasse: "hinweis",
text: rechte.configured
? "Erlaubte Operationen laufen ohne Rückfrage, alle anderen werden einzeln zur Freigabe vorgelegt, verbotene gar nicht erst."
: "Noch keine Rechte gesetzt: Es gilt die Vorgabe des Werkzeugs. Mit dem Speichern werden die Einstellungen unten verbindlich."
})
);
const tabelle = erstelleElement("table", { klasse: "daten-tabelle rechte-tabelle" });
const kopf = erstelleElement("tr", {});
for (const titel of ["Operation", "Einstellung", "Wirkung"]) {
kopf.append(erstelleElement("th", { text: titel }));
}
tabelle.append(kopf);
const auswahl = new Map();
for (const op of rechte.operations) {
const select = erstelleElement(
"select",
{ disabled: op.alwaysApproval },
erstelleElement("option", { value: "erlaubt", text: "erlaubt" }),
erstelleElement("option", { value: "freigabe", text: "einzeln freigeben" }),
erstelleElement("option", { value: "verboten", text: "verboten" })
);
// Ohne gesetzte Rechte die tatsaechliche Wirkung vorbelegen - sonst wuerde das erste
// Speichern z.B. das freie Lesen still in eine Freigabepflicht verwandeln.
select.value = rechte.configured
? op.setting
: op.effective === "auto" ? "erlaubt" : op.effective === "deny" ? "verboten" : "freigabe";
auswahl.set(op.operation, select);
const [wirkungText, wirkungArt] = WIRKUNG[op.effective] ?? [op.effective, "neutral"];
const wirkung = erstelleElement("td", {}, erstellePlakette(wirkungText, wirkungArt));
if (op.standingApprovalId) {
wirkung.append(" ", erstellePlakette(`Dauerfreigabe #${op.standingApprovalId}`, "neutral"));
}
wirkung.append(erstelleElement("div", {
klasse: "grund",
text: op.alwaysApproval ? "Änderungen an Agenten brauchen immer eine Freigabe." : op.reason
}));
const zeile = erstelleElement("tr", {});
zeile.append(erstelleElement("td", { text: op.operation }), erstelleElement("td", {}, select), wirkung);
tabelle.append(zeile);
}
box.append(tabelle);
const speichern = erstelleElement("button", {
type: "button",
klasse: "schaltflaeche primaer klein",
text: "Rechte speichern",
onclick: async () => {
const allowed = [];
const denied = [];
for (const [operation, select] of auswahl) {
if (select.disabled) continue;
if (select.value === "erlaubt") allowed.push(operation);
if (select.value === "verboten") denied.push(operation);
}
speichern.disabled = true;
try {
const res = await api(pfad, { method: "PUT", body: { allowed, denied } });
zeigeMeldung(res?.message ?? "Rechte gespeichert.", "erfolg");
await zeigeWerkzeugrechte(agentId, toolName, box);
} catch (err) {
zeigeMeldung(`Speichern fehlgeschlagen: ${err.message}`, "gefahr");
speichern.disabled = false;
}
}
});
box.append(speichern);
}
@@ -46,7 +46,8 @@ export async function rendereStaging() {
);
const listeBereich = erstelleElement("div", { id: "staging-liste-bereich" });
layout.append(kopfZeile, listeBereich);
const dauerBereich = erstelleElement("div", { id: "staging-dauer-bereich" });
layout.append(kopfZeile, listeBereich, dauerBereich);
container.append(layout);
await ladeFreigaben();
@@ -66,6 +67,7 @@ async function ladeFreigaben() {
// Aktualisiere Badge in Sidebar
aktualisiereStagingBadge(wartendeAufrufe.length);
ladeDauerfreigaben();
if (!wartendeAufrufe || wartendeAufrufe.length === 0) {
container.append(
@@ -82,11 +84,15 @@ async function ladeFreigaben() {
for (const anfrage of wartendeAufrufe) {
const kopfAktionen = erstelleElement("div", { klasse: "gruppe-horizontal" });
// #641: "Gleichartiges kuenftig erlauben" - nie vorausgewaehlt, auch nicht beim
// Vorschlag. Uebernehmen muss der Mensch.
const gleichartig = erstelleElement("input", { type: "checkbox", id: `gleichartig-${anfrage.id}` });
const freigebenBtn = erstelleElement(
"button",
{
klasse: "schaltflaeche primaer klein",
onclick: () => bestaetigeFreigabe(anfrage.id)
onclick: () => bestaetigeFreigabe(anfrage.id, gleichartig.checked)
},
erstelleIcon(PFADE.haken, 14),
erstelleElement("span", { text: " Freigeben" })
@@ -128,7 +134,36 @@ async function ladeFreigaben() {
});
argBox.append(pre);
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, [datenliste, argBox], {
const teile = [datenliste, argBox];
if (anfrage.suggestAllowSimilar) {
teile.push(
erstelleElement("div", {
klasse: "hinweis-box vorschlag",
text:
`Diese Operation von ${anfrage.agentId} wurde in den letzten 14 Tagen schon ` +
`${anfrage.previousApprovals}-mal freigegeben. Soll ${anfrage.tool}.${anfrage.action} ` +
`für diesen Agenten künftig ohne Rückfrage laufen?`
})
);
}
if (anfrage.canAllowSimilar) {
teile.push(
erstelleElement(
"label",
{ klasse: "gleichartig-wahl", for: `gleichartig-${anfrage.id}` },
gleichartig,
erstelleElement("span", {
text:
`Gleichartiges künftig erlauben: ${anfrage.agentId} darf ${anfrage.tool}.${anfrage.action} ` +
`ab jetzt ohne Rückfrage (widerrufbar unten unter „Dauerfreigaben“).`
})
)
);
}
const karte = erstelleKarte(`${anfrage.tool}.${anfrage.action || "?"}`, teile, {
aktionen: kopfAktionen
});
@@ -170,16 +205,87 @@ function aktualisiereStagingBadge(anzahl) {
}
}
async function bestaetigeFreigabe(id) {
async function bestaetigeFreigabe(id, gleichartig = false) {
try {
const res = await api(`/staging/${id}/approve`, { method: "POST" });
zeigeMeldung(`Aufruf freigegeben: ${res?.message ?? "Erfolgreich ausgeführt"}`, "erfolg");
const res = await api(`/staging/${id}/approve${gleichartig ? "?similar=true" : ""}`, { method: "POST" });
zeigeMeldung(
`Aufruf freigegeben${gleichartig ? " und künftig erlaubt" : ""}: ${res?.message ?? "Erfolgreich ausgeführt"}`,
"erfolg"
);
await ladeFreigaben();
} catch (err) {
zeigeMeldung(`Freigabe fehlgeschlagen: ${err.message}`, "gefahr");
}
}
/**
* #641: Die geltenden Dauerfreigaben - "Gleichartiges kuenftig erlauben" - mit Widerruf.
*/
async function ladeDauerfreigaben() {
const bereich = document.getElementById("staging-dauer-bereich");
if (!bereich) return;
let liste;
try {
liste = await api("/staging/standing");
} catch {
bereich.replaceChildren();
return;
}
const inhalt = [];
if (!Array.isArray(liste) || liste.length === 0) {
inhalt.push(
erstelleElement("p", {
klasse: "gedaempft",
text:
"Keine. Beim Freigeben lässt sich „Gleichartiges künftig erlauben“ wählen; " +
"dauerhafte Rechte stehen sonst in der Werkzeugkonfiguration des Agenten."
})
);
} else {
const tabelle = erstelleElement("table", { klasse: "daten-tabelle" });
const kopf = erstelleElement("tr", {});
for (const titel of ["Agent", "Operation", "Erteilt", "Anlass", ""]) {
kopf.append(erstelleElement("th", { text: titel }));
}
tabelle.append(kopf);
for (const d of liste) {
const zeile = erstelleElement("tr", {});
const widerrufen = erstelleElement(
"button",
{ klasse: "schaltflaeche gefahr flach klein", onclick: () => widerrufeDauerfreigabe(d) },
erstelleElement("span", { text: "Widerrufen" })
);
zeile.append(
erstelleElement("td", { text: d.agentId }),
erstelleElement("td", { text: `${d.tool}.${d.operation}` }),
erstelleElement("td", { text: `${formatiereDatumZeit(d.grantedAt)} (${d.grantedBy})` }),
erstelleElement("td", { text: d.sourceId ? `#${d.sourceId}` : "—" }),
erstelleElement("td", {}, widerrufen)
);
tabelle.append(zeile);
}
inhalt.push(tabelle);
}
bereich.replaceChildren(erstelleKarte("Dauerfreigaben", inhalt));
}
async function widerrufeDauerfreigabe(d) {
if (!confirm(`Dauerfreigabe widerrufen? ${d.agentId} muss ${d.tool}.${d.operation} dann wieder einzeln vorlegen.`)) {
return;
}
try {
const res = await api(`/staging/standing/${d.id}/revoke`, { method: "POST" });
zeigeMeldung(res?.message ?? "Widerrufen.", res?.success === false ? "gefahr" : "info");
await ladeDauerfreigaben();
} catch (err) {
zeigeMeldung(`Widerruf fehlgeschlagen: ${err.message}`, "gefahr");
}
}
function oeffneAblehnenDialog(id) {
const formular = erstelleElement("form", { klasse: "formular-raster" });
const grundInput = erstelleElement("input", {
+40 -24
View File
@@ -30,8 +30,9 @@ public sealed class DatabaseTool : IAgentTool
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.
Welche Operationen (SELECT, INSERT, UPDATE, ALTER ...) du ohne Rueckfrage ausfuehren darfst,
legt deine Werkzeugkonfiguration fest. Andere werden einzeln 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.
""";
@@ -67,9 +68,12 @@ 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.
/// #579: Was das Werkzeug ohnehin ablehnen wuerde, kommt nicht in die Freigabeliste -
/// unlesbares SQL, nicht freigegebene Tabellen und Collections.
///
/// <para>#641: Die Operation selbst (SELECT, INSERT, ALTER ...) prueft hier niemand mehr.
/// Was nicht erlaubt ist, entscheidet die Freigabestelle - und legt es einzeln vor, statt
/// es zu verweigern.</para>
/// </summary>
public string? Preflight(JsonElement input, AgentToolContext context)
{
@@ -77,16 +81,11 @@ public sealed class DatabaseTool : IAgentTool
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;
}
@@ -95,34 +94,51 @@ public sealed class DatabaseTool : IAgentTool
return "'sql' ist erforderlich.";
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
return Verweigerung(inspection, accessLevel);
return inspection.IsValid ? null : $"Sicherheitsfehler: {inspection.Error}";
}
/// <summary>Die Operationen, die sich je Agent erlauben lassen (#641).</summary>
public IReadOnlyList<string> Operationen { get; } =
["select", "insert", "update", "delete", "replace", "create", "alter", "drop", "truncate"];
/// <summary>
/// #609: 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.
/// #609/#641: Der Freigabeschluessel folgt dem Statement, nicht dem action-Argument - ein
/// INSERT ist "insert", auch wenn das Modell action "query" schickt.
/// </summary>
public string? StagingAktion(JsonElement input)
{
var sql = GetString(input, "sql");
if (string.IsNullOrWhiteSpace(sql))
return null; // MongoDB: dort gilt das action-Argument
if (!string.IsNullOrWhiteSpace(sql))
return SqlGuard.Operationsschluessel(sql);
return SqlGuard.Inspect(sql, ["*"]).Operation switch
return GetString(input, "action") switch
{
SqlOperation.Read => "query",
SqlOperation.Write => "write",
SqlOperation.Schema => "schema",
"query" => "select",
"insert" => "insert",
"upsert" => "update",
_ => null
};
}
private static string? Verweigerung(SqlInspection inspection, DatabaseAccessLevel accessLevel)
/// <summary>
/// Ohne <c>erlaubt</c> in der Konfiguration: Lesen laeuft, alles andere wird vorgelegt.
/// </summary>
public Core.Staging.StagingDecision? Vorgabe(string operation, IReadOnlyDictionary<string, object?> config)
=> operation == "select" ? Core.Staging.StagingDecision.Auto : Core.Staging.StagingDecision.Approve;
/// <summary>
/// Die eigene Rechtepruefung des Werkzeugs. Laeuft nur, wenn keine Freigabestelle den
/// Aufruf geprueft hat (<see cref="AgentToolContext.FreigabeGeprueft"/>) - dann gilt die
/// Zugriffsstufe als harte Grenze.
/// </summary>
private static string? Verweigerung(SqlInspection inspection, DatabaseAccessLevel accessLevel, bool freigabeGeprueft)
{
if (!inspection.IsValid)
return $"Sicherheitsfehler: {inspection.Error}";
if (freigabeGeprueft)
return null;
return inspection.Operation switch
{
SqlOperation.Schema when accessLevel != DatabaseAccessLevel.Admin =>
@@ -178,7 +194,7 @@ public sealed class DatabaseTool : IAgentTool
// werden dabei behandelt, bevor Operation und Tabellen bestimmt werden.
var inspection = SqlGuard.Inspect(sql, GetAllowedTables(context));
if (Verweigerung(inspection, accessLevel) is { } verweigert)
if (Verweigerung(inspection, accessLevel, context.FreigabeGeprueft) is { } verweigert)
return ToolResult.Fail(verweigert);
using DbConnection conn = type switch
@@ -245,7 +261,7 @@ public sealed class DatabaseTool : IAgentTool
}
else if (action == "insert")
{
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
if (accessLevel == DatabaseAccessLevel.ReadOnly && !context.FreigabeGeprueft) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
var docJson = input.TryGetProperty("document", out var d) ? d.GetString() : throw new ArgumentException("'document' erforderlich.");
var doc = BsonDocument.Parse(docJson);
await collection.InsertOneAsync(doc, cancellationToken: ct);
@@ -253,7 +269,7 @@ public sealed class DatabaseTool : IAgentTool
}
else // upsert
{
if (accessLevel == DatabaseAccessLevel.ReadOnly) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
if (accessLevel == DatabaseAccessLevel.ReadOnly && !context.FreigabeGeprueft) return ToolResult.Fail("Sicherheitsfehler: Schreibzugriff deaktiviert.");
return ToolResult.Fail("Upsert für MongoDB noch nicht voll implementiert.");
}
}
@@ -82,6 +82,30 @@ public static class SqlGuard
@"^[\s`\[""]*(?<table>[A-Za-z_][A-Za-z0-9_$]*(?:\.[A-Za-z_][A-Za-z0-9_$]*)?)",
RegexOptions.Compiled);
/// <summary>
/// Die Operation als Schluessel fuer die Rechte in der Werkzeugkonfiguration (#641):
/// lesende Statements (SELECT, SHOW, DESCRIBE, EXPLAIN, WITH) sind alle "select",
/// alle anderen ihr erstes Schluesselwort in Kleinschrift ("insert", "alter", ...).
/// <c>null</c>, wenn das Statement nicht lesbar ist.
/// </summary>
public static string? Operationsschluessel(string? sql)
{
if (string.IsNullOrWhiteSpace(sql))
return null;
var normalized = RemoveStringLiterals(sql);
var firstWord = normalized
.Split([' ', '\t', '\r', '\n', '(', ';'], StringSplitOptions.RemoveEmptyEntries)
.FirstOrDefault();
return DetermineOperation(normalized) switch
{
SqlOperation.Read => "select",
SqlOperation.Write or SqlOperation.Schema => firstWord!.ToLowerInvariant(),
_ => null
};
}
public static SqlInspection Inspect(string? sql, IReadOnlyCollection<string> allowedTables)
{
if (string.IsNullOrWhiteSpace(sql))
+41 -1
View File
@@ -167,6 +167,9 @@ public sealed record ForecastTaskDto(
public sealed record TaskWhenDto(string Kind, string Value, string TimeZone);
/// <summary>Ein eingefrorener Aufruf, der auf Freigabe wartet.</summary>
/// <param name="PreviousApprovals">#641: Wie oft dieselbe Operation dieses Agenten zuletzt schon freigegeben wurde.</param>
/// <param name="CanAllowSimilar">#641: Ob "Gleichartiges kuenftig erlauben" angeboten werden darf.</param>
/// <param name="SuggestAllowSimilar">#641: Ob die Oberflaeche die Dauerfreigabe vorschlagen soll.</param>
public sealed record StagedCallDto(
long Id,
string RunId,
@@ -176,7 +179,44 @@ public sealed record StagedCallDto(
string Proposal,
string Status,
string ArgumentsJson,
DateTime CreatedAt);
DateTime CreatedAt,
int PreviousApprovals = 0,
bool CanAllowSimilar = false,
bool SuggestAllowSimilar = false);
/// <summary>#641: Eine Dauerfreigabe - "Gleichartiges kuenftig erlauben".</summary>
public sealed record StandingApprovalDto(
long Id,
string AgentId,
string Tool,
string Operation,
string GrantedBy,
DateTime GrantedAt,
long? SourceId,
DateTime? RevokedAt,
string? RevokedBy);
/// <summary>
/// #641: Die Rechte eines Agenten an einem Werkzeug, je Operation.
/// <c>Setting</c>: "erlaubt", "verboten" oder "freigabe" (einzeln vorlegen) - so wie es in der
/// Konfiguration steht. <c>Effective</c>: was tatsaechlich geschieht ("auto", "approve",
/// "deny") - einschliesslich Dauerfreigaben und Werkzeugvorgabe.
/// </summary>
public sealed record ToolRightsDto(
string Tool,
bool Configured,
IReadOnlyList<ToolOperationDto> Operations);
public sealed record ToolOperationDto(
string Operation,
string Setting,
string Effective,
string Reason,
bool AlwaysApproval,
long? StandingApprovalId);
/// <summary>#641: Neue Rechte. <c>Allowed</c> <c>null</c> = Liste entfernen, Vorgabe des Werkzeugs gilt.</summary>
public sealed record ToolRightsInputDto(IReadOnlyList<string>? Allowed, IReadOnlyList<string>? Denied);
/// <summary>Warum ein Vorschlag abgelehnt wurde.</summary>
public sealed record RejectDto(string Reason);
+12 -3
View File
@@ -139,8 +139,10 @@ internal static class Mapping
/// Zusammenfassung davon. Sie sind zugleich Fremdtext aus einem Modell; die Anzeige
/// setzt sie als Text ein, nie als HTML.</para>
/// </summary>
public static StagedCallDto ToDto(this StagedCall call)
=> new(
public static StagedCallDto ToDto(this StagedCall call, int bisherFreigegeben = 0, bool dauerfreigabenAktiv = false)
{
var erlaubbar = dauerfreigabenAktiv && StagingService.IstDauerhaftErlaubbar(call);
return new(
call.Id,
call.RunId,
call.AgentId,
@@ -149,7 +151,14 @@ internal static class Mapping
call.Proposal,
call.Status.ToString(),
call.ArgumentsJson,
call.CreatedAt);
call.CreatedAt,
bisherFreigegeben,
erlaubbar,
erlaubbar && bisherFreigegeben >= StagingService.VorschlagAb);
}
public static StandingApprovalDto ToDto(this Dauerfreigabe d)
=> new(d.Id, d.AgentId, d.Tool, d.Operation, d.ErteiltVon, d.ErteiltAm, d.AnlassId, d.WiderrufenAm, d.WiderrufenVon);
public static UsageDto ToDto(this RunUsage usage)
=> new(
@@ -0,0 +1,55 @@
using ClawdDotNet.Core.Staging;
using ClawdDotNet.Core.Tools;
namespace ClawdDotNet.Web.Contracts;
/// <summary>
/// #641: Was ein Agent mit einem Werkzeug darf - je Operation, so wie es in der
/// Konfiguration steht, und so, wie es tatsaechlich eingestuft wird. Beides zu zeigen ist
/// Absicht: Eine Dauerfreigabe oder die Vorgabe des Werkzeugs kann eine Operation laufen
/// lassen, die in der Konfiguration nicht als erlaubt steht.
/// </summary>
public static class WerkzeugrechteAnsicht
{
public static ToolRightsDto Erstelle(
IAgentTool tool, IReadOnlyDictionary<string, object?> settings, IReadOnlyList<Dauerfreigabe> dauerfreigaben)
{
var rechte = Werkzeugrechte.Aus(settings);
var policy = new StagingPolicy();
// Auch Operationen zeigen, die in der Konfiguration stehen, das Werkzeug aber nicht
// (mehr) aufzaehlt - sonst waere ein Tippfehler unsichtbar.
var operationen = tool.Operationen
.Select(Werkzeugrechte.Normalisiere)
.Concat(rechte.Erlaubt?.Where(o => o != Werkzeugrechte.Alles) ?? [])
.Concat(rechte.Verboten)
.Distinct()
.ToList();
var zeilen = operationen.Select(op =>
{
var dauer = dauerfreigaben.FirstOrDefault(d => d.Operation == op);
var einstufung = FreigabeRegeln.Einstufen(
tool.Name, op, rechte, tool.Vorgabe(op, settings), policy, dauerfreigabe: dauer is not null);
var einstellung = rechte.IstVerboten(op) ? "verboten"
: rechte.IstErlaubt(op) ? "erlaubt"
: "freigabe";
return new ToolOperationDto(
op,
einstellung,
einstufung.Entscheidung switch
{
StagingDecision.Auto => "auto",
StagingDecision.Deny => "deny",
_ => "approve"
},
einstufung.Grund,
FreigabeRegeln.IstImmerFreigabe(tool.Name, op),
dauer?.Id);
}).ToList();
return new ToolRightsDto(tool.Name, rechte.IstKonfiguriert, zeilen);
}
}
+49 -3
View File
@@ -144,6 +144,27 @@ internal static class ApiEndpoints
return Results.Ok(Mapping.ToDto(toolName, settings));
});
// #641: Rechte je Werkzeug - was laeuft, was vorgelegt wird, was verboten ist.
agents.MapGet("/{agentId}/tools/{toolName}/rights", async (
AppHost host, string agentId, string toolName, CancellationToken ct) =>
{
if (host.AgentAdmin.Find(agentId) is not { } agent)
return Results.NotFound(new OutcomeDto(false, $"Kein Agent mit der ID '{agentId}'."));
if (!agent.Tools.TryGetValue(toolName, out var settings))
return Results.NotFound(new OutcomeDto(false, $"Werkzeug '{toolName}' ist nicht zugewiesen."));
if (host.Tools.Get(toolName) is not { } tool)
return Results.NotFound(new OutcomeDto(false, $"Werkzeug '{toolName}' ist nicht installiert."));
var dauer = host.Staging is { } service
? (await service.ListDauerfreigabenAsync(false, ct)).Where(d => d.AgentId == agentId && d.Tool == toolName).ToList()
: [];
return Results.Ok(WerkzeugrechteAnsicht.Erstelle(tool, settings.AsReadOnly(), dauer));
});
agents.MapPut("/{agentId}/tools/{toolName}/rights", (
AppHost host, string agentId, string toolName, ToolRightsInputDto input) =>
host.AgentAdmin.SetToolRights(agentId, toolName, input.Allowed, input.Denied ?? []).ToResponse());
agents.MapPost("/{agentId}/run", async (
AppHost host, string agentId, CancellationToken ct) =>
(await host.Runs.RunAgentAsync(agentId, message: null, ct)).ToResponse());
@@ -275,19 +296,44 @@ internal static class ApiEndpoints
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var pending = await service.ListPendingAsync(ct);
return Results.Ok(pending.Select(c => c.ToDto()).ToList());
var liste = new List<StagedCallDto>(pending.Count);
foreach (var call in pending)
liste.Add(call.ToDto(await service.BisherFreigegebenAsync(call, ct), service.KenntDauerfreigaben));
return Results.Ok(liste);
});
staging.MapPost("/{id:long}/approve", async (AppHost host, long id, CancellationToken ct) =>
// #641: similar=true - "Gleichartiges kuenftig erlauben".
staging.MapPost("/{id:long}/approve", async (
AppHost host, long id, CancellationToken ct, bool similar = false) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var result = await service.ApproveAsync(id, decidedBy: "webui", ct);
var result = await service.ApproveAsync(id, decidedBy: "webui", gleichartigErlauben: similar, ct);
return Results.Ok(new OutcomeDto(
result.Kind is Core.Staging.StagingResultKind.Executed, result.Message));
});
staging.MapGet("/standing", async (AppHost host, CancellationToken ct, bool revoked = false) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var liste = await service.ListDauerfreigabenAsync(revoked, ct);
return Results.Ok(liste.Select(d => d.ToDto()).ToList());
});
staging.MapPost("/standing/{id:long}/revoke", async (AppHost host, long id, CancellationToken ct) =>
{
if (host.Staging is not { } service)
return Unavailable("Keine Freigabestelle in dieser Instanz.");
var ok = await service.WiderrufenAsync(id, "webui", ct);
return Results.Ok(new OutcomeDto(ok, ok
? $"Dauerfreigabe #{id} widerrufen. Die Operation wird wieder einzeln vorgelegt."
: $"Dauerfreigabe #{id} gibt es nicht oder sie ist schon widerrufen."));
});
staging.MapPost("/{id:long}/reject", async (
AppHost host, long id, RejectDto input, CancellationToken ct) =>
{