diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6d7c340f9..71f48f431 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,7 +63,8 @@ Core checks every call against the agent's scope and caps, so a tool and a form - Reading a content type or its entries needs `read` on the type, and lists leave out types the agent may not read. - Writing an entry needs `create`, `update` or `delete` on its type, plus `read`. Every field the write changes must be allowed by the field list; a field sent back unchanged is not a change. - A type's definition covers every field, so creating, revising or proposing one needs a scope that does not narrow that type to some of its fields. -- A call past the credential's calls per period does not run. It returns an error result that names the cap, and refused calls do not count toward it. +- A call past the credential's calls per period does not run. It returns an error result that names the cap, and refused calls do not count toward it. A call is recorded as `running` before its tool runs and counts from then, so concurrent calls of one agent take turns at the cap, as its change set opens do at the daily cap. +- An agent reads, adds to, submits and reopens only the change sets it opened. People read every set. On a fresh database the admin asks for the first administrator at `/setup`; every other admin page needs a signed-in user. Data Protection keeps the keys that protect sign-in cookies, anti-forgery tokens and stored secrets in `DataProtection__KeysDirectory` (default `data-protection-keys` under the content root). Put it on storage that survives restarts and upgrades and that only the host's account can read; losing it signs everyone out. Sign-in, setup and password reset posts are limited per client address by `SignInRateLimit__PermitLimit` and `SignInRateLimit__Window`, ten a minute by default. A password reset link issued on `/users` works once and expires after `PasswordReset__TokenLifespan`, one day by default. Each agent connects with its own key, issued on `/agents/new` with a scope, caps and an expiry. The key is shown once, in the response that issues it, and only its SHA-256 hash is stored. A lost key cannot be recovered: issue a new one and revoke the old one on `/agents`. diff --git a/src/FluentCMS.Core/Agents/AgentPermissions.cs b/src/FluentCMS.Core/Agents/AgentPermissions.cs index 9a6ee3851..9234caf12 100644 --- a/src/FluentCMS.Core/Agents/AgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/AgentPermissions.cs @@ -88,6 +88,9 @@ public async Task CheckNewChangeSetAsync(CancellationToken cancellationT return Result.Success(); } + // Called inside the unit of work that opens the set, so concurrent opens by one agent are counted one at a time. + await activity.HoldAsync(credential.Id, cancellationToken); + var cap = credential.Caps.ChangeSetsPerDay; var opened = await activity.ChangeSetsOpenedSinceAsync(credential.Id, clock.GetUtcNow() - Day, cancellationToken); if (opened < cap) @@ -105,6 +108,28 @@ await RecordAsync( + $"(change sets per day). Add the operation to a change set that is still open, or wait until fewer than {cap} were opened in the last 24 hours."); } + public async Task CheckChangeSetReadAsync(ChangeSet changeSet, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(changeSet); + + if (currentInitiator.Current is SystemInitiator) + { + return Result.Success(); + } + + var agent = await AgentAsync(cancellationToken); + if (!agent.IsSuccess) + { + return Result.Failure(agent.Errors); + } + + return agent.Value is { } credential && !(changeSet.Initiator is AgentInitiator owner && owner.CredentialId == credential.Id) + ? Result.Failure( + AgentPermissionErrorCodes.NotOwner, + $"Change set {changeSet.Id} is not one the credential '{credential.DisplayName}' opened; an agent reads its own change sets only.") + : Result.Success(); + } + public async Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(changeSet); @@ -253,34 +278,48 @@ public async Task> ReadableTypesAsync(CancellationToken cance return scope.AllowsOperation(AgentOperation.Read) ? scope.AllowsType : _ => false; } - public async Task CheckToolCallAsync(CancellationToken cancellationToken = default) + public async Task ClaimToolCallAsync(ToolCall call, CancellationToken cancellationToken = default) { + ArgumentNullException.ThrowIfNull(call); + if (currentInitiator.Current is SystemInitiator system) { return SystemRefused(system, "call a tool"); } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); + var agent = await AgentAsync(cancellationToken); if (!agent.IsSuccess) { + await activity.StartToolCallAsync(call with { Outcome = ToolCallOutcome.Refused }, cancellationToken); + await scope.CommitAsync(cancellationToken); return Result.Failure(agent.Errors); } if (agent.Value is not { } credential) { + await scope.CommitAsync(cancellationToken); return Result.Success(); } + // One credential's calls take turns from here to the commit, so each count sees every call admitted before it. + await activity.HoldAsync(credential.Id, cancellationToken); + var caps = credential.Caps; var made = await activity.ToolCallsSinceAsync(credential.Id, clock.GetUtcNow() - caps.CallPeriod, cancellationToken); if (made < caps.CallsPerPeriod) { + await activity.StartToolCallAsync(call with { Outcome = ToolCallOutcome.Running }, cancellationToken); + await scope.CommitAsync(cancellationToken); return Result.Success(); } - await RecordAsync( + await activity.StartToolCallAsync(call with { Outcome = ToolCallOutcome.Refused }, cancellationToken); + await auditLog.AppendAsync( new NewAuditEvent("agent.calls_per_period_exceeded") { SubjectType = "agent_credential", SubjectId = credential.Id }, cancellationToken); + await scope.CommitAsync(cancellationToken); return Result.Failure( AgentPermissionErrorCodes.CapExceeded, @@ -390,10 +429,17 @@ private static Result Owns(AgentCredential credential, ChangeSet changeSet) return Result.Success(credential.Value); } - // The refused write leaves nothing, so the event commits on its own, or with the caller's unit of work when there is one. + // The refused write leaves nothing, so the event commits on its own, or with the caller's unit of work when there is one. That + // unit of work usually rolls back because of the refusal, so the event is appended again once it has, in a unit of work of its own. private async Task RecordAsync(NewAuditEvent refusal, CancellationToken cancellationToken) { await using var scope = await unitOfWork.BeginAsync(cancellationToken); + unitOfWork.AfterRollback(async afterwards => + { + await using var again = await unitOfWork.BeginAsync(afterwards); + await auditLog.AppendAsync(refusal, afterwards); + await again.CommitAsync(afterwards); + }); await auditLog.AppendAsync(refusal, cancellationToken); await scope.CommitAsync(cancellationToken); } diff --git a/src/FluentCMS.Core/Agents/IAgentActivity.cs b/src/FluentCMS.Core/Agents/IAgentActivity.cs index 0c29962fb..3e8ef1d12 100644 --- a/src/FluentCMS.Core/Agents/IAgentActivity.cs +++ b/src/FluentCMS.Core/Agents/IAgentActivity.cs @@ -5,6 +5,13 @@ public interface IAgentActivity { Task ChangeSetsOpenedSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default); - // Calls a refusal stopped are left out, so an agent over its cap is not kept there by its own retries. + // Calls a refusal stopped are left out, so an agent over its cap is not kept there by its own retries; running ones count. Task ToolCallsSinceAsync(Guid credentialId, DateTimeOffset since, CancellationToken cancellationToken = default); + + // Holds the credential until the current unit of work ends, so concurrent capped actions of one agent are counted one at a time. + // Called first in the unit of work, before the count it protects. + Task HoldAsync(Guid credentialId, CancellationToken cancellationToken = default); + + // Adds the call to the current unit of work, so it is recorded in the same transaction as the count that let it run. + Task StartToolCallAsync(ToolCall call, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Agents/IAgentPermissions.cs b/src/FluentCMS.Core/Agents/IAgentPermissions.cs index be1a02831..1b390e42a 100644 --- a/src/FluentCMS.Core/Agents/IAgentPermissions.cs +++ b/src/FluentCMS.Core/Agents/IAgentPermissions.cs @@ -13,8 +13,13 @@ public interface IAgentPermissions // An agent submits its own set and reopens it after failed checks; every other move is the checks', routing's or a person's. Task CheckTransitionAsync(ChangeSet changeSet, ChangeSetState target, CancellationToken cancellationToken = default); + // Called inside the unit of work that opens the set: it holds the agent's credential until that unit of work ends, so concurrent + // opens by one agent are counted one at a time. Outside one it is only a pre-check, and the open counts again. Task CheckNewChangeSetAsync(CancellationToken cancellationToken = default); + // An agent reads the change sets it opened, as it writes only those; people and the system read every set. + Task CheckChangeSetReadAsync(ChangeSet changeSet, CancellationToken cancellationToken = default); + // Called with the agent's own set as read for the write and the new operation added in memory, before anything is written, // or ahead of a write with the number of operations it will add. Task CheckChangeSetSizeAsync(ChangeSet changeSet, int adding = 0, CancellationToken cancellationToken = default); @@ -33,6 +38,7 @@ Task CheckContentAsync( // For lists: every type for a person, the types in scope for an agent that may read, and none for a refused credential. Task> ReadableTypesAsync(CancellationToken cancellationToken = default); - // One more tool call within the credential's calls per period (SEC-04), before the tool runs. - Task CheckToolCallAsync(CancellationToken cancellationToken = default); + // Records the call before its tool runs: as running when it is within the credential's calls per period (SEC-04), or as refused, + // with its security event, when it is not. The count and the record share one transaction that one credential's calls take turns on. + Task ClaimToolCallAsync(ToolCall call, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Agents/IToolCallLog.cs b/src/FluentCMS.Core/Agents/IToolCallLog.cs index 2fe7da3e6..76060179d 100644 --- a/src/FluentCMS.Core/Agents/IToolCallLog.cs +++ b/src/FluentCMS.Core/Agents/IToolCallLog.cs @@ -5,4 +5,7 @@ public interface IToolCallLog { // Writes in a transaction of its own, never in a caller's unit of work, so a call whose own changes rolled back is still on record. Task AppendAsync(ToolCall call, CancellationToken cancellationToken = default); + + // The outcome of a call recorded as running when it started, in a transaction of its own. + Task FinishAsync(Guid callId, ToolCallOutcome outcome, TimeSpan duration, CancellationToken cancellationToken = default); } diff --git a/src/FluentCMS.Core/Agents/ToolCall.cs b/src/FluentCMS.Core/Agents/ToolCall.cs index 083b76d12..85fec0c92 100644 --- a/src/FluentCMS.Core/Agents/ToolCall.cs +++ b/src/FluentCMS.Core/Agents/ToolCall.cs @@ -13,6 +13,9 @@ public enum ToolCallOutcome // The tool never ran because the call was over the credential's calls per period; not counted toward that cap. Refused, + + // The tool is running; the outcome replaces this when it ends. It counts toward the cap, so calls made at once take their places in turn. + Running, } // One tool call an agent made (MCP-07). Arguments and results are not kept: they hold content, and the audit log records every diff --git a/src/FluentCMS.Core/Persistence/IUnitOfWork.cs b/src/FluentCMS.Core/Persistence/IUnitOfWork.cs index 7eb68673a..9b0874740 100644 --- a/src/FluentCMS.Core/Persistence/IUnitOfWork.cs +++ b/src/FluentCMS.Core/Persistence/IUnitOfWork.cs @@ -5,6 +5,10 @@ public interface IUnitOfWork { // Nested calls join the outer unit of work; only the outermost scope commits the transaction. Task BeginAsync(CancellationToken cancellationToken = default); + + // Runs the step once the outermost scope ends without committing, in a unit of work of its own, for what must outlive a + // rolled-back change, such as the security event of a refusal (SEC-08). Without an open scope, or once it commits, it never runs. + void AfterRollback(Func step); } public interface IUnitOfWorkScope : IAsyncDisposable diff --git a/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs b/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs index 72f652add..47f6de175 100644 --- a/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs +++ b/src/FluentCMS.Infrastructure/Agents/AgentActivity.cs @@ -32,4 +32,18 @@ public Task ToolCallsSinceAsync(Guid credentialId, DateTimeOffset since, Ca """) .CountAsync(cancellationToken); } + + // A write to the credential's row: PostgreSQL holds the row lock and SQLite the write lock until the transaction ends. + public async Task HoldAsync(Guid credentialId, CancellationToken cancellationToken = default) + { + await context.Database.ExecuteSqlAsync($"""UPDATE "AgentCredentials" SET "DisplayName" = "DisplayName" WHERE "Id" = {credentialId}""", cancellationToken); + } + + public Task StartToolCallAsync(ToolCall call, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(call); + + context.ToolCalls.Add(ToolCallRow.From(call)); + return Task.CompletedTask; + } } diff --git a/src/FluentCMS.Infrastructure/Agents/ToolCallLog.cs b/src/FluentCMS.Infrastructure/Agents/ToolCallLog.cs index 60c4b5e1f..b0116681a 100644 --- a/src/FluentCMS.Infrastructure/Agents/ToolCallLog.cs +++ b/src/FluentCMS.Infrastructure/Agents/ToolCallLog.cs @@ -1,5 +1,6 @@ using FluentCMS.Core.Agents; using FluentCMS.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace FluentCMS.Infrastructure.Agents; @@ -16,4 +17,12 @@ public async Task AppendAsync(ToolCall call, CancellationToken cancellationToken context.ToolCalls.Add(ToolCallRow.From(call)); await context.SaveChangesAsync(cancellationToken); } + + public async Task FinishAsync(Guid callId, ToolCallOutcome outcome, TimeSpan duration, CancellationToken cancellationToken = default) + { + await using var scope = scopes.CreateAsyncScope(); + await scope.ServiceProvider.GetRequiredService().ToolCalls + .Where(row => row.Id == callId && row.Outcome == ToolCallOutcome.Running) + .ExecuteUpdateAsync(setters => setters.SetProperty(row => row.Outcome, outcome).SetProperty(row => row.Duration, duration), cancellationToken); + } } diff --git a/src/FluentCMS.Infrastructure/Agents/ToolCallRow.cs b/src/FluentCMS.Infrastructure/Agents/ToolCallRow.cs index 91e683c22..66c4ecd3a 100644 --- a/src/FluentCMS.Infrastructure/Agents/ToolCallRow.cs +++ b/src/FluentCMS.Infrastructure/Agents/ToolCallRow.cs @@ -2,7 +2,8 @@ namespace FluentCMS.Infrastructure.Agents; -// The flat shape of a ToolCall in the ToolCalls table; rows are inserted and never changed. +// The flat shape of a ToolCall in the ToolCalls table; a row is inserted when its call starts, and only a running call's outcome and +// duration change after that. public sealed class ToolCallRow { public Guid Id { get; set; } diff --git a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs index 070269234..2e5779b2d 100644 --- a/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs +++ b/src/FluentCMS.Infrastructure/ChangeSets/ChangeSetDirectory.cs @@ -13,7 +13,8 @@ namespace FluentCMS.Infrastructure.ChangeSets; -// Expected failures are found before the unit of work begins, so they leave a caller's outer unit of work able to commit. +// Expected failures are found before the unit of work begins, so they leave a caller's outer unit of work able to commit. An agent's +// daily cap is the exception: it is counted inside, so concurrent opens take turns, and its refusal rolls the caller's unit of work back. // Reads never track rows, and updates go straight to the database, so a long-lived context always sees what is stored. internal sealed class ChangeSetDirectory( FluentCmsDbContext context, @@ -33,8 +34,13 @@ internal sealed class ChangeSetDirectory( public async Task> GetAsync(Guid id, CancellationToken cancellationToken = default) { var stored = await ReadAsync(id, cancellationToken); + if (stored is null) + { + return NotFound(id); + } - return stored is null ? NotFound(id) : Result.Success(stored.ChangeSet); + var readable = await permissions.CheckChangeSetReadAsync(stored.ChangeSet, cancellationToken); + return readable.IsSuccess ? Result.Success(stored.ChangeSet) : Result.Failure(readable.Errors); } public async Task> DiffAsync(Guid id, CancellationToken cancellationToken = default) @@ -45,6 +51,12 @@ public async Task> DiffAsync(Guid id, CancellationToken ca return Result.Failure(NotFound(id).Errors); } + var readable = await permissions.CheckChangeSetReadAsync(stored.ChangeSet, cancellationToken); + if (!readable.IsSuccess) + { + return Result.Failure(readable.Errors); + } + var set = stored.ChangeSet; var definitions = await DefinitionsAsync(set.Operations, cancellationToken); var entries = await EntryDiffsAsync(set, cancellationToken); @@ -85,13 +97,14 @@ public async Task> OpenAsync(NewChangeSet changeSet, Cancellat return opened; } + // Counted inside the unit of work, after holding the agent's credential, so concurrent opens cannot all pass the cap. + await using var scope = await unitOfWork.BeginAsync(cancellationToken); var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - await using var scope = await unitOfWork.BeginAsync(cancellationToken); await InsertAsync(opened.Value, cancellationToken); return await CommitAsync(scope, opened.Value, Conflict(opened.Value.Id), cancellationToken); @@ -138,13 +151,13 @@ public async Task> AddToSessionAsync( return Result.Failure(previousVersion.Errors); } + await using var scope = await unitOfWork.BeginAsync(cancellationToken); var allowed = await permissions.CheckNewChangeSetAsync(cancellationToken); if (!allowed.IsSuccess) { return Result.Failure(allowed.Errors); } - await using var scope = await unitOfWork.BeginAsync(cancellationToken); await InsertAsync(set, cancellationToken); await InsertOperationAsync(set, set.Operations.Count - 1, previousVersion.Value, cancellationToken); diff --git a/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs b/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs index 15528872f..aa042dee3 100644 --- a/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs +++ b/src/FluentCMS.Infrastructure/Persistence/UnitOfWork.cs @@ -1,11 +1,13 @@ using FluentCMS.Core.Persistence; using Microsoft.EntityFrameworkCore.Storage; +using Microsoft.Extensions.Logging; namespace FluentCMS.Infrastructure.Persistence; // One database transaction per outermost scope; inner scopes flush into it and cannot commit it on their own. -internal sealed class UnitOfWork(FluentCmsDbContext context) : IUnitOfWork +internal sealed class UnitOfWork(FluentCmsDbContext context, ILogger logger) : IUnitOfWork { + private readonly List> _afterRollback = []; private IDbContextTransaction? _transaction; private int _depth; private bool _abandoned; @@ -22,6 +24,16 @@ public async Task BeginAsync(CancellationToken cancellationTok return new Scope(this); } + public void AfterRollback(Func step) + { + ArgumentNullException.ThrowIfNull(step); + + if (_depth > 0) + { + _afterRollback.Add(step); + } + } + private async Task CommitAsync(CancellationToken cancellationToken) { if (_abandoned) @@ -47,6 +59,9 @@ private async ValueTask EndAsync(bool committed) return; } + List> steps = _abandoned ? [.. _afterRollback] : []; + _afterRollback.Clear(); + // Disposing an uncommitted transaction rolls it back; the tracker is cleared so nothing from it leaks into the next scope. await _transaction!.DisposeAsync(); _transaction = null; @@ -55,6 +70,19 @@ private async ValueTask EndAsync(bool committed) { context.ChangeTracker.Clear(); } + + // Each step begins a unit of work of its own; a failing one is logged, because this runs while the caller's scope ends. + foreach (var step in steps) + { + try + { + await step(CancellationToken.None); + } + catch (Exception exception) + { + logger.LogError(exception, "A step that runs after a rolled-back unit of work failed."); + } + } } private sealed class Scope(UnitOfWork owner) : IUnitOfWorkScope diff --git a/src/FluentCMS.Mcp/ToolCallRecording.cs b/src/FluentCMS.Mcp/ToolCallRecording.cs index c7f88a9c1..213a17f71 100644 --- a/src/FluentCMS.Mcp/ToolCallRecording.cs +++ b/src/FluentCMS.Mcp/ToolCallRecording.cs @@ -9,8 +9,9 @@ namespace FluentCMS.Mcp; // Every tool call is recorded with the agent that made it and, when the agent declares them in the call's _meta, its model and -// task id (MCP-07). _meta travels with the call, so declaring works the same over HTTP and stdio. A call past the credential's -// calls per period is refused before the tool runs, and recorded as refused (SEC-04). +// task id (MCP-07). _meta travels with the call, so declaring works the same over HTTP and stdio. A call is recorded as running before +// its tool runs, in the transaction that counts it toward the credential's calls per period, so concurrent calls cannot all pass the cap; +// a call past it is recorded as refused and its tool never runs (SEC-04). public static class ToolCallRecording { public const string ModelKey = "fluentcms/model"; @@ -18,53 +19,69 @@ public static class ToolCallRecording internal static McpRequestFilter Filter => next => async (context, cancellationToken) => { + // Both transports refuse a call without an accepted key, so a call that reaches a tool always has an agent. + if (ClaimsInitiator.From(context.User) is not AgentInitiator agent) + { + return await next(context, cancellationToken); + } + var services = context.Server.Services!; var clock = services.GetRequiredService(); var startedAt = clock.GetUtcNow(); var started = clock.GetTimestamp(); - var outcome = ToolCallOutcome.Errored; + var call = new ToolCall + { + Id = Guid.CreateVersion7(startedAt), + Timestamp = startedAt, + AgentCredentialId = agent.CredentialId, + AgentName = agent.DisplayName, + Tool = context.Params?.Name ?? string.Empty, + Model = Declared(context.Params?.Meta, ModelKey), + TaskId = Declared(context.Params?.Meta, TaskKey), + Outcome = ToolCallOutcome.Running, + Duration = TimeSpan.Zero, + }; + CallToolResult? refusal; try { - if (await RefusalAsync(services, cancellationToken) is { } refusal) - { - outcome = ToolCallOutcome.Refused; - return refusal; - } + refusal = await ClaimAsync(services, call, cancellationToken); + } + catch (Exception) + { + // The claim rolled back with the call it recorded, so the call is logged again on its own, and its tool never runs. + await RecordAsync(services, call, log => log.AppendAsync( + call with { Outcome = ToolCallOutcome.Errored, Duration = clock.GetElapsedTime(started) }, + CancellationToken.None)); + throw; + } + if (refusal is not null) + { + return refusal; + } + + var outcome = ToolCallOutcome.Errored; + try + { var result = await next(context, cancellationToken); outcome = result.IsError == true ? ToolCallOutcome.Failed : ToolCallOutcome.Succeeded; return result; } finally { - // Both transports refuse a call without an accepted key, so a call that reaches a tool always has an agent. - if (ClaimsInitiator.From(context.User) is AgentInitiator agent) - { - var call = new ToolCall - { - Id = Guid.CreateVersion7(startedAt), - Timestamp = startedAt, - AgentCredentialId = agent.CredentialId, - AgentName = agent.DisplayName, - Tool = context.Params?.Name ?? string.Empty, - Model = Declared(context.Params?.Meta, ModelKey), - TaskId = Declared(context.Params?.Meta, TaskKey), - Outcome = outcome, - Duration = clock.GetElapsedTime(started), - }; - - await RecordAsync(services, call); - } + var duration = clock.GetElapsedTime(started); + await RecordAsync(services, call, log => log.FinishAsync(call.Id, outcome, duration, CancellationToken.None)); } }; - // Over its calls per period, or under a credential refused since its key was accepted, the tool does not run and the agent is - // told why in a tool result it can read (SEC-04, UX-03). The check gets a service scope of its own; the caller is still the agent. - private static async Task RefusalAsync(IServiceProvider services, CancellationToken cancellationToken) + // Within its calls per period the call is recorded as running and the tool runs; past it, or under a credential refused since its + // key was accepted, it is recorded as refused and the agent is told why in a tool result it can read (SEC-04, UX-03). The claim + // gets a service scope of its own; the caller is still the agent. + private static async Task ClaimAsync(IServiceProvider services, ToolCall call, CancellationToken cancellationToken) { await using var scope = services.CreateAsyncScope(); - var allowed = await scope.ServiceProvider.GetRequiredService().CheckToolCallAsync(cancellationToken); + var allowed = await scope.ServiceProvider.GetRequiredService().ClaimToolCallAsync(call, cancellationToken); return allowed.IsSuccess ? null @@ -75,18 +92,18 @@ public static class ToolCallRecording }; } - // A call that already ran is not failed afterwards because its record could not be written; the failure is logged instead. - private static async Task RecordAsync(IServiceProvider services, ToolCall call) + // A call is not failed again because its record could not be written; that failure is logged instead. + private static async Task RecordAsync(IServiceProvider services, ToolCall call, Func write) { try { - await services.GetRequiredService().AppendAsync(call, CancellationToken.None); + await write(services.GetRequiredService()); } catch (Exception exception) { services.GetRequiredService() .CreateLogger(typeof(ToolCallRecording)) - .LogError(exception, "The call of tool {Tool} by agent credential {AgentCredentialId} could not be recorded.", call.Tool, call.AgentCredentialId); + .LogError(exception, "The outcome of tool {Tool} for agent credential {AgentCredentialId} could not be recorded.", call.Tool, call.AgentCredentialId); } } diff --git a/tests/FluentCMS.IntegrationTests/Agents/ToolCallLogTests.cs b/tests/FluentCMS.IntegrationTests/Agents/ToolCallLogTests.cs index ced033615..c17cfeb6b 100644 --- a/tests/FluentCMS.IntegrationTests/Agents/ToolCallLogTests.cs +++ b/tests/FluentCMS.IntegrationTests/Agents/ToolCallLogTests.cs @@ -31,6 +31,25 @@ public async Task A_call_reads_back_as_it_was_appended(string provider) Assert.Equal(TimeSpan.FromMilliseconds(12), row.Duration); } + // Only a call still running gets an outcome, so a finish that arrives twice leaves the first one. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_running_call_is_finished_once(string provider) + { + await using var scenario = await AgentCredentialScenario.CreateAsync(provider); + var agent = (await scenario.IssueAsync()).Credential; + var call = Call(agent) with { Outcome = ToolCallOutcome.Running, Duration = TimeSpan.Zero }; + var log = scenario.Services.GetRequiredService(); + await log.AppendAsync(call); + + await log.FinishAsync(call.Id, ToolCallOutcome.Succeeded, TimeSpan.FromMilliseconds(30)); + await log.FinishAsync(call.Id, ToolCallOutcome.Errored, TimeSpan.FromMilliseconds(40)); + + var row = Assert.Single(await scenario.QueryAsync(context => context.ToolCalls.AsNoTracking().ToListAsync())); + Assert.Equal((ToolCallOutcome.Succeeded, TimeSpan.FromMilliseconds(30)), (row.Outcome, row.Duration)); + } + private static ToolCall Call(AgentCredential agent) { return new ToolCall diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs index 2b27ce445..2aa66c6c2 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/AgentPermissionTests.cs @@ -226,6 +226,82 @@ public async Task A_change_set_past_the_agents_daily_cap_is_refused_until_the_da Assert.True((await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync))).IsSuccess); } + // Each open holds the agent's credential before it counts, so concurrent opens take turns and only the cap's worth are stored. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_ten_concurrent_opens_by_an_agent_with_a_daily_cap_of_two_two_are_stored(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + await scenario.UpdateAgentAsync(row => row.ChangeSetsPerDay = 2); + + var opens = await Task.WhenAll(Enumerable.Range(0, 10).Select(_ => Task.Run(() => scenario.RunAsync(directory => directory.OpenAsync(CatalogSync))))); + + Assert.Equal(2, opens.Count(open => open.IsSuccess)); + Assert.Equal( + Enumerable.Repeat(AgentPermissionErrorCodes.CapExceeded, 8), + opens.Where(open => !open.IsSuccess).Select(open => Assert.Single(open.Errors).Code)); + Assert.Equal(2, (await scenario.ChangeSetRowsAsync()).Count); + Assert.Equal(8, await scenario.QueryAsync(context => context.AuditEvents.CountAsync(row => row.Operation == "agent.change_sets_per_day_exceeded"))); + } + + // The refusal is a security event (SEC-08), so it outlives the caller's unit of work, which the refusal rolls back. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_cap_refused_inside_a_unit_of_work_that_rolls_back_keeps_its_event(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + await scenario.UpdateAgentAsync(row => row.ChangeSetsPerDay = 1); + + await using (var scope = scenario.Services.CreateAsyncScope()) + { + var work = await scope.ServiceProvider.GetRequiredService().BeginAsync(); + var directory = scope.ServiceProvider.GetRequiredService(); + + Assert.True((await directory.OpenAsync(CatalogSync)).IsSuccess); + Assert.Equal(AgentPermissionErrorCodes.CapExceeded, Assert.Single((await directory.OpenAsync(CatalogSync)).Errors).Code); + await work.DisposeAsync(); + } + + Assert.Empty(await scenario.ChangeSetRowsAsync()); + var refusal = Assert.Single(await scenario.QueryAsync(context => context.AuditEvents + .AsNoTracking() + .Where(row => row.Operation == "agent.change_sets_per_day_exceeded") + .ToListAsync())); + Assert.Equal(("agent_credential", Agent.CredentialId), (refusal.SubjectType, refusal.SubjectId!.Value)); + Assert.Equal(Agent.CredentialId, refusal.AgentCredentialId); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task An_agent_reads_its_own_change_sets_only_and_a_person_reads_every_set(string provider) + { + await using var scenario = await ChangeSetScenario.CreateAsync(provider); + var own = (await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync))).Value; + var persons = (await scenario.AsPersonAsync(directory => directory.OpenAsync(CatalogSync))).Value; + var other = new AgentInitiator(Guid.NewGuid(), "other-agent"); + await scenario.AddAgentAsync(other); + scenario.Initiator = other; + var others = (await scenario.RunAsync(directory => directory.OpenAsync(CatalogSync))).Value; + scenario.Initiator = Agent; + + Assert.True((await scenario.RunAsync(directory => directory.GetAsync(own.Id))).IsSuccess); + Assert.True((await scenario.RunAsync(directory => directory.DiffAsync(own.Id))).IsSuccess); + foreach (var id in new[] { persons.Id, others.Id }) + { + var read = await scenario.RunAsync(directory => directory.GetAsync(id)); + var diff = await scenario.RunAsync(directory => directory.DiffAsync(id)); + + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Assert.Single(read.Errors).Code); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Assert.Single(diff.Errors).Code); + } + + Assert.True((await scenario.AsPersonAsync(directory => directory.GetAsync(others.Id))).IsSuccess); + Assert.True((await scenario.AsPersonAsync(directory => directory.DiffAsync(own.Id))).IsSuccess); + } + [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] diff --git a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs index b30c0a215..e47954f0b 100644 --- a/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs +++ b/tests/FluentCMS.IntegrationTests/ChangeSets/ChangeSetScenario.cs @@ -68,13 +68,23 @@ public static async Task CreateAsync(string provider) var scenario = new ChangeSetScenario(database, services, initiator, clock, auditFailure); await scenario.QueryAsync(async context => { - context.AgentCredentials.Add(AgentCredential()); + context.AgentCredentials.Add(AgentCredential(Agent)); return await context.SaveChangesAsync(); }); return scenario; } + // Stores a credential for another agent with the same scope and caps as Agent. + public Task AddAgentAsync(AgentInitiator agent) + { + return QueryAsync(async context => + { + context.AgentCredentials.Add(AgentCredential(agent)); + return await context.SaveChangesAsync(); + }); + } + // As a person, who stands in for the checks, routing and the desk, whose moves an agent cannot make (MCP-06). public async Task AsPersonAsync(Func> action) { @@ -270,13 +280,13 @@ public static ChangeOperation Delete(ContentType type, int baseVersion) return ChangeOperation.Create(OperationKind.Delete, ItemKind.Schema, type.Id, baseVersion, null).Value; } - // The stored credential behind Agent, scoped to the site with room to spare, so only a test that lowers a cap meets one. - private static AgentCredentialRow AgentCredential() + // The stored credential behind an agent, scoped to the site with room to spare, so only a test that lowers a cap meets one. + private static AgentCredentialRow AgentCredential(AgentInitiator agent) { return new AgentCredentialRow { - Id = Agent.CredentialId, - DisplayName = Agent.DisplayName, + Id = agent.CredentialId, + DisplayName = agent.DisplayName, KeyHash = RandomNumberGenerator.GetBytes(32), IssuedAt = new DateTimeOffset(2026, 10, 1, 0, 0, 0, TimeSpan.Zero), ExpiresAt = new DateTimeOffset(2027, 10, 1, 0, 0, 0, TimeSpan.Zero), diff --git a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs index 9728b8f42..b798f68ad 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/BuiltInToolsTests.cs @@ -1,13 +1,16 @@ using System.Text.Json; using System.Text.Json.Nodes; +using FluentCMS.Core.Agents; using FluentCMS.Core.ChangeSets; using FluentCMS.Core.Extensions; using FluentCMS.Core.Results; using FluentCMS.Core.Schema; +using FluentCMS.Core.Sites; using FluentCMS.Core.Tools; using FluentCMS.Host; using FluentCMS.Infrastructure.Persistence; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol; @@ -70,21 +73,30 @@ public async Task An_agent_reads_a_change_set_and_its_diff_with_the_change_set_t await using var host = await AdminHost.StartAsync(install, settings: database.Configuration); var administrator = await host.CreateFirstAdministratorAsync(); var product = await CreateTypesAsync(host, administrator); - var changeSet = await host.AsUserAsync(administrator, async directory => + var issued = await McpAgents.IssueAsync(host, administrator, McpAgents.Credential(DateTimeOffset.UtcNow) with + { + Scope = AgentScope.Create([Site.Default.Id], null, null, [AgentOperation.Read, AgentOperation.Update]).Value, + }); + var changeSet = await AsAgentAsync(host, issued.Credential, async directory => { var opened = (await directory.OpenAsync(new NewChangeSet("Add a price to products.", ["https://example.com/spec"]))).Value; var update = ChangeOperation.Create(OperationKind.Update, ItemKind.Schema, product.Id, 1, 2).Value; return (await directory.AddOperationAsync(opened.Id, update)).Value; }); - var issued = await McpAgents.IssueAsync(host, administrator); + var personsSet = await host.AsUserAsync(administrator, async directory => + (await directory.OpenAsync(new NewChangeSet("A person's set.", []))).Value); await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); var set = await CallAsync(client, "get_change_set", new() { ["id"] = changeSet.Id.ToString() }); var diff = await CallAsync(client, "get_change_set_diff", new() { ["id"] = changeSet.Id.ToString() }); + var notOwn = await FailAsync(client, "get_change_set", new() { ["id"] = personsSet.Id.ToString() }); + var notOwnDiff = await FailAsync(client, "get_change_set_diff", new() { ["id"] = personsSet.Id.ToString() }); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Code(notOwn)); + Assert.Equal(AgentPermissionErrorCodes.NotOwner, Code(notOwnDiff)); Assert.Equal("open", (string?)set["state"]); Assert.Equal("Add a price to products.", (string?)set["rationale"]); - Assert.Equal(("user", "Test administrator"), ((string?)set["initiator"]!["kind"], (string?)set["initiator"]!["name"])); + Assert.Equal(("agent", "pipeline-agent"), ((string?)set["initiator"]!["kind"], (string?)set["initiator"]!["name"])); var operation = Assert.Single(set["operations"]!.AsArray())!; Assert.Equal(("update", "schema", product.Id.ToString(), 1, 2), ((string?)operation["kind"], (string?)operation["itemKind"], (string?)operation["itemId"], (int)operation["baseVersion"]!, (int)operation["proposedVersion"]!)); @@ -183,6 +195,22 @@ public async Task Two_tools_with_one_name_stop_the_host_at_startup() } } + // As the agent, outside a tool, for setup a test needs to own: the directories see the credential as they would over MCP. + private static async Task AsAgentAsync(AdminHost host, AgentCredential credential, Func> action) + { + var accessor = host.App.Services.GetRequiredService(); + await using var scope = host.App.Services.CreateAsyncScope(); + accessor.HttpContext = new DefaultHttpContext { RequestServices = scope.ServiceProvider, User = AgentPrincipal.For(credential, "test") }; + try + { + return await action(scope.ServiceProvider.GetRequiredService()); + } + finally + { + accessor.HttpContext = null; + } + } + private static async Task CreateTypesAsync(AdminHost host, Guid administrator) { return await host.AsUserAsync(administrator, async directory => diff --git a/tests/FluentCMS.IntegrationTests/Mcp/CallsPerPeriodTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/CallsPerPeriodTests.cs index 8620d7bda..238aba1f9 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/CallsPerPeriodTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/CallsPerPeriodTests.cs @@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace FluentCMS.IntegrationTests.Mcp; @@ -65,4 +66,86 @@ public async Task A_call_past_the_cap_is_refused_until_the_period_moves_on_and_r Assert.NotEqual(true, (await client.CallToolAsync("whoami")).IsError); Assert.True((await client.CallToolAsync("whoami")).IsError); } + + // Each call holds the agent's credential while it is counted and recorded as running, so concurrent calls take turns at the cap. + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task Of_twelve_concurrent_calls_with_a_cap_of_three_three_run_and_nine_are_refused(string provider) + { + var runs = 0; + var slow = McpServerTool.Create( + async () => + { + Interlocked.Increment(ref runs); + await Task.Delay(TimeSpan.FromMilliseconds(300)); + return "done"; + }, + new McpServerToolCreateOptions { Name = "slow" }); + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration, configure: builder => builder.Services.AddSingleton(slow)); + var issued = await McpAgents.IssueAsync(host, await host.CreateFirstAdministratorAsync(), Capped(3)); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + + var results = await Task.WhenAll(Enumerable.Range(0, 12).Select(_ => client.CallToolAsync("slow").AsTask())); + + Assert.Equal(3, results.Count(result => result.IsError != true)); + Assert.Equal(3, runs); + var outcomes = await OutcomesAsync(host); + Assert.Equal(3, outcomes.Count(outcome => outcome == ToolCallOutcome.Succeeded)); + Assert.Equal(9, outcomes.Count(outcome => outcome == ToolCallOutcome.Refused)); + Assert.Equal(12, outcomes.Count); + } + + [Theory] + [InlineData(DatabaseProviders.Sqlite)] + [PostgresData] + public async Task A_call_is_logged_as_running_before_its_tool_finishes_and_counts_toward_the_cap(string provider) + { + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var waiting = McpServerTool.Create( + async () => + { + started.TrySetResult(); + await release.Task; + return "done"; + }, + new McpServerToolCreateOptions { Name = "waiting" }); + using var install = new TemporaryInstall(); + await using var database = await TemporaryDatabase.CreateAsync(provider); + await using var host = await AdminHost.StartAsync(install, settings: database.Configuration, configure: builder => builder.Services.AddSingleton(waiting)); + var issued = await McpAgents.IssueAsync(host, await host.CreateFirstAdministratorAsync(), Capped(1)); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + + var first = client.CallToolAsync("waiting").AsTask(); + await started.Task.WaitAsync(TimeSpan.FromSeconds(30)); + + Assert.Equal([ToolCallOutcome.Running], await OutcomesAsync(host)); + var refused = await client.CallToolAsync("waiting"); + Assert.True(refused.IsError); + Assert.Contains("its cap is 1 (calls per period)", Assert.IsType(Assert.Single(refused.Content)).Text, StringComparison.Ordinal); + + release.SetResult(); + + Assert.NotEqual(true, (await first).IsError); + Assert.Equal([ToolCallOutcome.Succeeded, ToolCallOutcome.Refused], await OutcomesAsync(host)); + } + + private static NewAgentCredential Capped(int callsPerHour) + { + return McpAgents.Credential(DateTimeOffset.UtcNow) with + { + Caps = AgentCaps.Create(callsPerHour, TimeSpan.FromHours(1), 5m, TimeSpan.FromDays(1), 200, 20).Value, + }; + } + + // In the order the calls were made. + private static async Task> OutcomesAsync(AdminHost host) + { + await using var scope = host.App.Services.CreateAsyncScope(); + var rows = await scope.ServiceProvider.GetRequiredService().ToolCalls.AsNoTracking().ToListAsync(); + return [.. rows.OrderBy(row => row.Timestamp).ThenBy(row => row.Id).Select(row => row.Outcome)]; + } } diff --git a/tests/FluentCMS.IntegrationTests/Mcp/ToolCallRecordingTests.cs b/tests/FluentCMS.IntegrationTests/Mcp/ToolCallRecordingTests.cs index 7ec60336e..be448ba5e 100644 --- a/tests/FluentCMS.IntegrationTests/Mcp/ToolCallRecordingTests.cs +++ b/tests/FluentCMS.IntegrationTests/Mcp/ToolCallRecordingTests.cs @@ -95,7 +95,8 @@ public async Task Each_outcome_is_recorded_and_the_caller_gets_the_same_answer_a Assert.Equal([ToolCallOutcome.Failed, ToolCallOutcome.Errored, ToolCallOutcome.Errored], calls.Select(call => call.Outcome)); } - // The record is written after the tool's own unit of work has ended, so it stays when the tool's changes roll back. + // The call is recorded before the tool runs and finished after, each outside the tool's unit of work, so it stays when the tool's + // changes roll back. [Theory] [InlineData(DatabaseProviders.Sqlite)] [PostgresData] @@ -117,6 +118,29 @@ public async Task A_call_whose_changes_roll_back_is_still_recorded(string provid Assert.Equal(ToolCallOutcome.Errored, call.Outcome); } + // The claim's own record rolls back with it, so the call is logged on its own; a trigger stands in for a database that fails. + [Fact] + public async Task A_call_whose_claim_fails_is_recorded_as_errored_and_its_tool_does_not_run() + { + using var install = new TemporaryInstall(); + await using var host = await AdminHost.StartAsync(install, configure: AddTestTools); + var issued = await McpAgents.IssueAsync(host, await host.CreateFirstAdministratorAsync()); + await using var client = await McpAgents.ConnectOverHttpAsync(host, issued.Key); + await using (var scope = host.App.Services.CreateAsyncScope()) + { + await scope.ServiceProvider.GetRequiredService().Database.ExecuteSqlRawAsync( + """CREATE TRIGGER "FailHold" BEFORE UPDATE ON "AgentCredentials" BEGIN SELECT RAISE(ABORT, 'The database is unreachable.'); END;"""); + } + + // The tool would answer "Refused."; the SDK's own error text shows it never ran. + var result = await client.CallToolAsync("fails"); + + Assert.True(result.IsError); + Assert.DoesNotContain("Refused.", Assert.IsType(Assert.Single(result.Content)).Text, StringComparison.Ordinal); + var call = Assert.Single(await CallsAsync(host)); + Assert.Equal((issued.Credential.Id, "fails", ToolCallOutcome.Errored), (call.AgentCredentialId, call.Tool, call.Outcome)); + } + [Fact] public async Task A_call_over_stdio_is_recorded_with_its_agent() { diff --git a/tests/FluentCMS.Tests/Audit/AuditExporterTests.cs b/tests/FluentCMS.Tests/Audit/AuditExporterTests.cs index 8d291384c..85613e662 100644 --- a/tests/FluentCMS.Tests/Audit/AuditExporterTests.cs +++ b/tests/FluentCMS.Tests/Audit/AuditExporterTests.cs @@ -267,6 +267,10 @@ private sealed class RecordingTrail(Stream? destination = null) : IUnitOfWork, I public List DestinationLengthAtCommit { get; } = []; + public void AfterRollback(Func step) + { + } + public Task BeginAsync(CancellationToken cancellationToken = default) { Begun++; diff --git a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs index b61b13b20..0786647d9 100644 --- a/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs +++ b/tests/FluentCMS.Tests/Identity/SystemInitiatorTests.cs @@ -139,7 +139,7 @@ await Permissions.CheckContentAsync("product-page", AgentOperation.Delete), await Permissions.CheckDefinitionAsync("product-page", AgentOperation.Update), await Permissions.CheckNewChangeSetAsync(), await Permissions.CheckChangeSetSizeAsync(set, 1), - await Permissions.CheckToolCallAsync(), + await Permissions.ClaimToolCallAsync(new ToolCall { Id = Guid.NewGuid(), Timestamp = DateTimeOffset.UtcNow, AgentCredentialId = Guid.NewGuid(), AgentName = "none", Tool = "whoami", Outcome = ToolCallOutcome.Running, Duration = TimeSpan.Zero }), ]; Assert.All(refused, result => Assert.Equal(AgentPermissionErrorCodes.SystemNotPermitted, Assert.Single(result.Errors).Code)); @@ -189,6 +189,10 @@ public Task AppendAsync(NewAuditEvent entry, CancellationToken cance private sealed class NoUnitOfWork : IUnitOfWork { + public void AfterRollback(Func step) + { + } + public Task BeginAsync(CancellationToken cancellationToken = default) { return Task.FromResult(new Scope()); @@ -219,6 +223,16 @@ public Task ToolCallsSinceAsync(Guid credentialId, DateTimeOffset since, Ca { return Task.FromResult(0); } + + public Task HoldAsync(Guid credentialId, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + + public Task StartToolCallAsync(ToolCall call, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } } private sealed class OneCredential(AgentCredential credential) : IAgentCredentialDirectory