Freigabe durch die Leitung innerhalb von Grenzen (#687)
- UnitConfig.Approvals: Operationen, Hoechstzahl je Tag, Stunden je Freigabe; nur der Mensch setzt sie (OrgAdministration.SetApprovals mit Audit, Teilmengenregel nach oben, Senken oben senkt unten mit; Agentenaenderungen/Sonderbudget nie freigebbar) - LeadApprovals: Vorlage geht an die naechste Leitung, deren Einheit die Operation erlaubt; nie eigene Aufrufe, nie eine Leitung mit Fremdinhalt, Tagesdeckel, Frist. Entscheidung ueber Escalation approve_call/reject_call, im Audit als lead:<id> - Der Mensch sieht alles und kann zuerst entscheiden; danach wird die Leitungsaufgabe geschlossen. Nach der Frist nur noch der Mensch. - StagedCalls: Approver, ApproverUntil, ApproverTaskId (Nachruestung) - WebUI: Hinweis "bei Leitung" in der Freigabestelle, Zaehler ohne diese, Quote je Leitung; Karte in der Organisationsansicht; i18n en/de - Tests: LeadApprovalsTests (13), OrgAdministrationTests (+2) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
dafb8241a0
commit
f51186269c
@@ -251,6 +251,43 @@ public sealed class OrgAdministrationTests : IDisposable
|
||||
Find("newbie").UnitId.ShouldBe("amazon");
|
||||
}
|
||||
|
||||
// ═══════════════════ approvals by the lead (#687) ═══════════════════
|
||||
|
||||
private UnitConfig Unit(string id) => _test.Host.Instance.Units.Single(u => u.Id == id);
|
||||
|
||||
[Fact]
|
||||
public void A_unit_grants_its_lead_approvals_but_never_changes_to_agents()
|
||||
{
|
||||
BuildMarketing();
|
||||
|
||||
Org.SetApprovals("marketing", ["AgentEditor.update_soul"]).Message.ShouldContain("always stays with a human");
|
||||
Org.SetApprovals("marketing", ["AgentEditor"]).Ok.ShouldBeFalse("a whole tool with such an operation reads as a grant for it");
|
||||
Org.SetApprovals("marketing", [".x"]).Ok.ShouldBeFalse();
|
||||
|
||||
var set = Org.SetApprovals("marketing", ["Database.insert", "FileRW.delete"], perDay: 5, hours: 2, reason: "night shift");
|
||||
|
||||
set.Ok.ShouldBeTrue(set.Message);
|
||||
Unit("marketing").Approvals!.Operations.ShouldBe(["Database.insert", "FileRW.delete"]);
|
||||
Unit("marketing").Approvals!.PerDay.ShouldBe(5);
|
||||
Org.SetApprovals("marketing", []).Ok.ShouldBeTrue();
|
||||
Unit("marketing").Approvals.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void A_sub_unit_grants_no_more_than_the_units_above_and_shrinks_with_them()
|
||||
{
|
||||
BuildMarketing();
|
||||
Org.SetApprovals("marketing", ["Database"]).Ok.ShouldBeTrue();
|
||||
|
||||
Org.SetApprovals("amazon", ["Mail.send"]).Message.ShouldContain("Marketing");
|
||||
Org.SetApprovals("amazon", ["Database.insert", "Database.update"]).Ok.ShouldBeTrue("'Database' above covers each of its operations");
|
||||
|
||||
var lowered = Org.SetApprovals("marketing", ["Database.insert"]);
|
||||
|
||||
lowered.Message.ShouldContain("Amazon");
|
||||
Unit("amazon").Approvals!.Operations.ShouldBe(["Database.insert"]);
|
||||
}
|
||||
|
||||
// ═══════════════════ the top (#690) ═══════════════════
|
||||
|
||||
private void MakeDecider(string id)
|
||||
|
||||
@@ -75,6 +75,20 @@ internal sealed class InMemoryStagingRepository : IStagingRepository
|
||||
}
|
||||
}
|
||||
|
||||
public Task SetApproverAsync(long id, string approver, DateTime until, string taskId, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_calls.TryGetValue(id, out var call) && call.Status == StagingStatus.Pending)
|
||||
_calls[id] = call with { Approver = approver, ApproverUntil = until, ApproverTaskId = taskId };
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<StagedCall>> ListRoutedSinceAsync(DateTime since, CancellationToken ct)
|
||||
=> Task.FromResult<IReadOnlyList<StagedCall>>(
|
||||
_calls.Values.Where(c => c.Approver is not null && c.CreatedAt >= since).OrderBy(c => c.Id).ToList());
|
||||
|
||||
/// <summary>Verschiebt den Anlagezeitpunkt eines Vorschlags in die Vergangenheit - fuer Verfallstests.</summary>
|
||||
public void Altern(long id, TimeSpan um)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
using ClawdDotNet.Core.Config;
|
||||
using ClawdDotNet.Core.Organisation;
|
||||
using ClawdDotNet.Core.Staging;
|
||||
using ClawdDotNet.Core.Storage;
|
||||
using ClawdDotNet.Core.Tasks;
|
||||
using ClawdDotNet.Core.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Shouldly;
|
||||
|
||||
namespace ClawdDotNet.Core.Tests.Staging;
|
||||
|
||||
/// <summary>
|
||||
/// #687: A lead approves within the limits its unit grants — never its own calls, never a
|
||||
/// change to agents, never while it reads foreign content — and the human stays above it.
|
||||
/// </summary>
|
||||
public sealed class LeadApprovalsTests : IDisposable
|
||||
{
|
||||
private readonly string _directory = Path.Combine(Path.GetTempPath(), "clawd-tests", "leads-" + Guid.NewGuid().ToString("N")[..8]);
|
||||
private readonly SqliteStagingRepository _staging;
|
||||
private readonly SqliteTaskRepository _tasks;
|
||||
private readonly TaskboardService _board;
|
||||
private readonly FakeTimeProvider _clock = new(DateTimeOffset.UtcNow);
|
||||
private readonly Executor _executor = new();
|
||||
private readonly StagingService _service;
|
||||
private readonly LeadApprovals _leads;
|
||||
private readonly HashSet<string> _foreign = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>ops (ops-lead; grants Database.insert, 2 a day, 1 h) › team (team-lead, grants nothing; worker).</summary>
|
||||
private readonly UnitConfig _ops = new()
|
||||
{
|
||||
Id = "ops", Name = "Operations", LeaderAgentId = "ops-lead",
|
||||
Approvals = new UnitApprovals { Operations = ["Database.insert", "AgentEditor"], PerDay = 2, Hours = 1 }
|
||||
};
|
||||
|
||||
private OrgChart Org => new(
|
||||
[_ops, new UnitConfig { Id = "team", Name = "Team", ParentId = "ops", LeaderAgentId = "team-lead" }],
|
||||
[
|
||||
new AgentConfig { AgentId = "ops-lead", UnitId = "ops" },
|
||||
new AgentConfig { AgentId = "team-lead", UnitId = "team" },
|
||||
new AgentConfig { AgentId = "worker", UnitId = "team" }
|
||||
]);
|
||||
|
||||
public LeadApprovalsTests()
|
||||
{
|
||||
var storage = new SqliteStorage(Path.Combine(_directory, "state.db"));
|
||||
_staging = new SqliteStagingRepository(storage);
|
||||
_tasks = new SqliteTaskRepository(storage);
|
||||
_board = new TaskboardService(_tasks, Path.Combine(_directory, "tasks"));
|
||||
_service = new StagingService(_staging, _executor, _board, NullLoggerFactory.Instance);
|
||||
_leads = new LeadApprovals(_staging, _board, () => Org,
|
||||
a => _foreign.Contains(a.AgentId) ? new AgentTrust(TrustTier.ReadsForeignContent, ["WebFetch"]) : AgentTrust.Standard,
|
||||
NullLogger.Instance, _clock) { Service = _service };
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteStorage.ReleasePooledConnections(_directory);
|
||||
try { Directory.Delete(_directory, recursive: true); }
|
||||
catch { /* cleaning up is secondary */ }
|
||||
}
|
||||
|
||||
private async Task<(StagedCall Call, string? Line)> Stage(string agent, string tool = "Database", string action = "insert")
|
||||
{
|
||||
var call = new StagedCall { RunId = "run-1", AgentId = agent, Tool = tool, Action = action, ArgumentsJson = $"{{\"action\":\"{action}\"}}" };
|
||||
var id = await _staging.AppendAsync(call, default);
|
||||
var line = await _leads.RouteAsync(call with { Id = id }, default);
|
||||
return ((await _staging.GetAsync(id, default))!, line);
|
||||
}
|
||||
|
||||
// ═══════════════════ where it goes ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task A_call_goes_to_the_nearest_lead_whose_unit_grants_it()
|
||||
{
|
||||
var (call, line) = await Stage("worker");
|
||||
|
||||
call.Approver.ShouldBe("ops-lead", "team-lead's unit grants nothing — the grant above counts");
|
||||
line!.ShouldContain("ops-lead");
|
||||
var inbox = (await _tasks.GetAsync(call.ApproverTaskId!, default))!;
|
||||
inbox.Assignee.ShouldBe("@ops-lead");
|
||||
inbox.Body.ShouldContain("<untrusted_content", Case.Sensitive, customMessage: "the frozen call is data for the lead");
|
||||
inbox.Body.ShouldContain("approve_call");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task What_the_unit_does_not_grant_goes_to_the_human()
|
||||
=> (await Stage("worker", "Mail", "send")).Call.Approver.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public async Task A_change_to_agents_stays_with_the_human_even_under_a_whole_tool_grant()
|
||||
=> (await Stage("worker", "AgentEditor", "update_soul")).Call.Approver.ShouldBeNull();
|
||||
|
||||
[Fact]
|
||||
public async Task A_lead_never_gets_its_own_call()
|
||||
{
|
||||
(await Stage("ops-lead")).Call.Approver.ShouldBeNull("nobody above ops-lead grants anything");
|
||||
(await Stage("team-lead")).Call.Approver.ShouldBe("ops-lead");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_lead_that_reads_foreign_content_does_not_approve()
|
||||
{
|
||||
_foreign.Add("ops-lead");
|
||||
|
||||
(await Stage("worker")).Call.Approver.ShouldBeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Beyond_the_daily_cap_the_human_decides()
|
||||
{
|
||||
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
|
||||
(await Stage("worker")).Call.Approver.ShouldBe("ops-lead");
|
||||
|
||||
(await Stage("worker")).Call.Approver.ShouldBeNull();
|
||||
}
|
||||
|
||||
// ═══════════════════ how it is decided ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task The_lead_approves_the_frozen_call_and_the_decision_is_marked_as_the_leads()
|
||||
{
|
||||
var (call, _) = await Stage("worker");
|
||||
|
||||
var decided = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: true, "", default);
|
||||
|
||||
decided.Ok.ShouldBeTrue(decided.Message);
|
||||
_executor.Calls.ShouldBe(1);
|
||||
var after = (await _staging.GetAsync(call.Id, default))!;
|
||||
after.Status.ShouldBe(StagingStatus.Executed);
|
||||
after.DecidedBy.ShouldBe("lead:ops-lead");
|
||||
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Done);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_rejection_needs_its_reason()
|
||||
{
|
||||
var (call, _) = await Stage("worker");
|
||||
|
||||
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, " ", default)).Ok.ShouldBeFalse();
|
||||
var rejected = await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, approve: false, "Wrong table.", default);
|
||||
|
||||
rejected.Ok.ShouldBeTrue(rejected.Message);
|
||||
(await _staging.GetAsync(call.Id, default))!.Status.ShouldBe(StagingStatus.Rejected);
|
||||
_executor.Calls.ShouldBe(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Only_the_routed_lead_decides_and_only_in_time()
|
||||
{
|
||||
var (call, _) = await Stage("worker");
|
||||
|
||||
(await _leads.DecideAsync(call.Id, "team-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("not yours");
|
||||
(await _leads.DecideAsync(call.Id, "ops-lead", new AgentTrust(TrustTier.ReadsForeignContent, ["Mail"]), true, "", default))
|
||||
.Message.ShouldContain("outside");
|
||||
|
||||
_clock.Advance(TimeSpan.FromHours(1.5));
|
||||
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("run out");
|
||||
(await _leads.PendingForAsync("ops-lead", default)).ShouldBeEmpty();
|
||||
(await _service.ApproveAsync(call.Id, "webui", default)).Kind.ShouldBe(StagingResultKind.Executed, "the human decides it now");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task When_the_human_decides_first_the_leads_task_is_closed()
|
||||
{
|
||||
var (call, _) = await Stage("worker");
|
||||
|
||||
await _service.RejectAsync(call.Id, "webui", "Not now.", default);
|
||||
|
||||
(await _tasks.GetAsync(call.ApproverTaskId!, default))!.Status.ShouldBe(TaskItemStatus.Canceled);
|
||||
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("already decided");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_grant_taken_away_ends_the_leads_say()
|
||||
{
|
||||
var (call, _) = await Stage("worker");
|
||||
_ops.Approvals = null;
|
||||
|
||||
(await _leads.DecideAsync(call.Id, "ops-lead", AgentTrust.Standard, true, "", default)).Message.ShouldContain("no longer grants");
|
||||
}
|
||||
|
||||
// ═══════════════════ the rate ═══════════════════
|
||||
|
||||
[Fact]
|
||||
public async Task The_approval_rate_per_lead_is_counted()
|
||||
{
|
||||
var (first, _) = await Stage("worker");
|
||||
var (second, _) = await Stage("worker");
|
||||
await _leads.DecideAsync(first.Id, "ops-lead", AgentTrust.Standard, true, "", default);
|
||||
await _leads.DecideAsync(second.Id, "ops-lead", AgentTrust.Standard, false, "No.", default);
|
||||
|
||||
var stats = (await _leads.StatsAsync(30, default)).Single();
|
||||
|
||||
stats.LeadId.ShouldBe("ops-lead");
|
||||
stats.Routed.ShouldBe(2);
|
||||
stats.Approved.ShouldBe(1);
|
||||
stats.Rejected.ShouldBe(1);
|
||||
stats.ApprovalRate.ShouldBe(0.5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task The_gate_tells_the_agent_who_decides()
|
||||
{
|
||||
var gate = new StagingGate(new StagingPolicy(), _staging) { Route = _leads.RouteAsync };
|
||||
|
||||
var intercept = await gate.InterceptAsync("worker", "inst", "run-2", "Database", "{\"action\":\"insert\",\"table\":\"orders\"}", default, "insert");
|
||||
|
||||
intercept.Outcome.ShouldBe(StagingOutcome.Staged);
|
||||
intercept.Message.ShouldContain("ops-lead");
|
||||
intercept.Message.ShouldNotContain("menschlicher Freigabe");
|
||||
}
|
||||
|
||||
private sealed class Executor : IFrozenCallExecutor
|
||||
{
|
||||
public int Calls { get; private set; }
|
||||
|
||||
public Task<string> ExecuteApprovedCallAsync(string agentId, string tool, string argumentsJson, string runId, CancellationToken ct)
|
||||
{
|
||||
Calls++;
|
||||
return Task.FromResult("1 row inserted");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user