#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:
@@ -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);
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user