diff --git a/appsettings.Development.json b/appsettings.Development.json index d6f0345..26c189d 100644 --- a/appsettings.Development.json +++ b/appsettings.Development.json @@ -27,6 +27,10 @@ "OtlpExporter": "http://localhost:4317/" }, "CasCap": { + "AuditConfig": { + "ApprovalEnabled": false, + "NeverRequireApproval": [ "UnlockHouseDoor", "SetSmartPlugPower", "SwitchOffAllHouseLights" ] + }, "FeatureConfig": { "EnabledFeatures": "Fronius,Buderus,DoorBird,Knx" }, diff --git a/appsettings.json b/appsettings.json index 84a9bac..45653de 100644 --- a/appsettings.json +++ b/appsettings.json @@ -28,6 +28,15 @@ "OtlpExporter": "http://opentelemetry-collector.monitoring.svc:4317" }, "CasCap": { + "AuditConfig": { + "Enabled": true, + "RedisStreamKey": "audit:mcp:calls", + "MaxStreamLength": 10000, + "ApprovalEnabled": true, + "ApprovalTimeoutMs": 60000, + "AlwaysRequireApproval": [], + "NeverRequireApproval": [] + }, "CachingConfig": { "RemoteCacheConnectionString": "redis-master.data.svc:6379,allowAdmin=true,abortConnect=false,connectTimeout=1000", "DistributedLockingEnabled": true, diff --git a/src/CasCap.App.Server/Program.cs b/src/CasCap.App.Server/Program.cs index 3253f4c..c4158fc 100644 --- a/src/CasCap.App.Server/Program.cs +++ b/src/CasCap.App.Server/Program.cs @@ -32,6 +32,9 @@ // SystemMcpQueryService is referenced by all agents — register unconditionally. builder.Services.AddSystemMcp(); + // Register MCP audit middleware and configuration. + builder.Services.AddMcpAudit(); + // Register SignalR services unconditionally so IHubContext<> is always resolvable for // HausHub sinks discovered during assembly scanning (e.g. HausHubSinkBuderusService). // The hub endpoint mapping and Redis backplane are configured later when SignalRHub is enabled. diff --git a/src/CasCap.SmartHaus/Attributes/RequiresApprovalAttribute.cs b/src/CasCap.SmartHaus/Attributes/RequiresApprovalAttribute.cs new file mode 100644 index 0000000..b5f21c9 --- /dev/null +++ b/src/CasCap.SmartHaus/Attributes/RequiresApprovalAttribute.cs @@ -0,0 +1,16 @@ +namespace CasCap.Attributes; + +/// +/// Marks an MCP tool method as requiring explicit human approval before execution. +/// +/// +/// When applied to a method decorated with , +/// the will gate execution behind a Signal poll +/// (or console confirmation in development) before invoking the tool. +/// +[AttributeUsage(AttributeTargets.Method)] +public sealed class RequiresApprovalAttribute : Attribute +{ + /// Human-readable reason displayed in the approval prompt. + public string? Reason { get; init; } +} diff --git a/src/CasCap.SmartHaus/Extensions/HausServiceCollectionExtensions.cs b/src/CasCap.SmartHaus/Extensions/HausServiceCollectionExtensions.cs index 7a5cc1b..3b0bba0 100644 --- a/src/CasCap.SmartHaus/Extensions/HausServiceCollectionExtensions.cs +++ b/src/CasCap.SmartHaus/Extensions/HausServiceCollectionExtensions.cs @@ -9,6 +9,16 @@ namespace Microsoft.Extensions.DependencyInjection; public static class HausServiceCollectionExtensions { + /// + /// Registers the and singleton + /// for MCP tool call auditing and human-in-the-loop approval. + /// + public static void AddMcpAudit(this IServiceCollection services) + { + services.AddCasCapConfiguration(); + services.TryAddSingleton(); + } + /// /// Registers and its configuration dependencies /// (, ). @@ -76,8 +86,14 @@ public static void AddComms(this WebApplicationBuilder builder, bool lite = fals /// Optional root AI configuration supplying shared /// and . /// + /// + /// Optional delegate to configure the pipeline (e.g. add middleware). + /// When null and the is registered, the audit middleware + /// is wired automatically. + /// public static (IChatClient chatClient, AIAgent agent, string instructions) CreateAgent(this WebApplicationBuilder builder, - ProviderConfig provider, AgentConfig agentConfig, IServiceProvider serviceProvider, List? tools = null, string? otelSourceName = null, AIConfig? aiConfig = null) + ProviderConfig provider, AgentConfig agentConfig, IServiceProvider serviceProvider, List? tools = null, + string? otelSourceName = null, AIConfig? aiConfig = null, Action? configureAgent = null) { // Infrastructure auth (k8s ingress basic auth) is only needed for Ollama // when running outside the cluster. OpenAI/AzureOpenAI auth is handled separately. @@ -102,12 +118,26 @@ public static (IChatClient chatClient, AIAgent agent, string instructions) Creat : null; return AgentExtensions.CreateAgent(provider, agentConfig, httpClient, tools, + configureAgent: configureAgent ?? BuildDefaultAgentMiddleware(serviceProvider), instructionsAssembly: typeof(HausServiceCollectionExtensions).Assembly, aiConfig: aiConfig, otelSourceName: otelSourceName, tokenCredential: tokenCredential); } + /// + /// Builds a default agent middleware pipeline that wires + /// when it is registered in the service provider. + /// + private static Action? BuildDefaultAgentMiddleware(IServiceProvider serviceProvider) + { + var auditMiddleware = serviceProvider.GetService(); + if (auditMiddleware is null) + return null; + + return b => b.Use(auditMiddleware.InvokeAsync); + } + /// /// Registers the hub-side event sinks /// (Console and Metrics) enabled in diff --git a/src/CasCap.SmartHaus/GlobalUsings.cs b/src/CasCap.SmartHaus/GlobalUsings.cs index 9c62bdd..ccc2f8a 100644 --- a/src/CasCap.SmartHaus/GlobalUsings.cs +++ b/src/CasCap.SmartHaus/GlobalUsings.cs @@ -1,5 +1,6 @@ global using Azure; global using CasCap.Abstractions; +global using CasCap.Attributes; global using CasCap.Common.Abstractions; global using CasCap.Common.Exceptions; global using CasCap.Common.Extensions; diff --git a/src/CasCap.SmartHaus/Models/McpAuditRecord.cs b/src/CasCap.SmartHaus/Models/McpAuditRecord.cs new file mode 100644 index 0000000..19e23a9 --- /dev/null +++ b/src/CasCap.SmartHaus/Models/McpAuditRecord.cs @@ -0,0 +1,46 @@ +namespace CasCap.Models; + +/// +/// Immutable audit record emitted for every MCP tool invocation. +/// +/// +/// Written to structured logs and optionally to a Redis Stream by . +/// +public sealed record McpAuditRecord +{ + /// UTC timestamp when the tool invocation started. + [Description("UTC timestamp of the invocation.")] + public required DateTime TimestampUtc { get; init; } + + /// The tool name as registered in the MCP server (snake_case). + [Description("MCP tool name.")] + public required string ToolName { get; init; } + + /// Serialised arguments passed to the tool. + [Description("JSON-encoded arguments dictionary.")] + public required string Arguments { get; init; } + + /// Duration of the tool execution. + [Description("Execution duration.")] + public required TimeSpan Duration { get; init; } + + /// Whether the invocation completed successfully. + [Description("True if the tool returned without throwing.")] + public required bool Success { get; init; } + + /// Exception type name if the invocation failed; otherwise null. + [Description("Exception type if failed.")] + public string? ErrorType { get; init; } + + /// Exception message if the invocation failed; otherwise null. + [Description("Error message if failed.")] + public string? ErrorMessage { get; init; } + + /// Whether human approval was required for this invocation. + [Description("True if the tool required human approval.")] + public bool ApprovalRequired { get; init; } + + /// Whether approval was granted (null if not required). + [Description("True if approved, false if denied, null if not required.")] + public bool? ApprovalGranted { get; init; } +} diff --git a/src/CasCap.SmartHaus/Models/_AuditConfig.cs b/src/CasCap.SmartHaus/Models/_AuditConfig.cs new file mode 100644 index 0000000..9946835 --- /dev/null +++ b/src/CasCap.SmartHaus/Models/_AuditConfig.cs @@ -0,0 +1,56 @@ +namespace CasCap.Models; + +/// +/// Configuration for the MCP tool call auditing and human-in-the-loop approval gate. +/// +/// +/// Bound from the CasCap:AuditConfig section in appsettings.json. +/// Controls both the audit logging sink and the approval workflow behaviour. +/// +public record AuditConfig : IAppConfig +{ + /// + public static string ConfigurationSectionName => $"{nameof(CasCap)}:{nameof(AuditConfig)}"; + + /// Whether MCP tool call auditing is enabled. + /// Defaults to true. + public bool Enabled { get; init; } = true; + + /// Redis Stream key for durable audit history. + /// Defaults to "audit:mcp:calls". + [Required, MinLength(1)] + public string RedisStreamKey { get; init; } = "audit:mcp:calls"; + + /// Maximum number of entries retained in the Redis audit stream (MAXLEN). + /// Defaults to 10000. Set to 0 to disable trimming. + [Range(0, int.MaxValue)] + public int MaxStreamLength { get; init; } = 10_000; + + /// Whether the human-in-the-loop approval gate is enabled. + /// Defaults to true. + public bool ApprovalEnabled { get; init; } = true; + + /// Timeout in milliseconds for waiting for an approval response. + /// Defaults to 60000 (60 seconds). Used by . + [Range(1000, 300_000)] + public int ApprovalTimeoutMs { get; init; } = 60_000; + + /// + /// Tool names that always require approval regardless of the . + /// + /// + /// Matching is case-insensitive — use either PascalCase method names (e.g. UnlockHouseDoor) + /// or snake_case registry names (e.g. unlock_house_door). + /// Config-driven override for operational control without redeployment. + /// + public string[] AlwaysRequireApproval { get; init; } = []; + + /// + /// Tool names that are exempt from approval even if decorated with . + /// + /// + /// Matching is case-insensitive — use either PascalCase method names or snake_case registry names. + /// Useful for development environments or trusted automation scenarios. + /// + public string[] NeverRequireApproval { get; init; } = []; +} diff --git a/src/CasCap.SmartHaus/Services/Mcp/FrontDoorMcpQueryService.cs b/src/CasCap.SmartHaus/Services/Mcp/FrontDoorMcpQueryService.cs index ec51bb1..1cb1a2d 100644 --- a/src/CasCap.SmartHaus/Services/Mcp/FrontDoorMcpQueryService.cs +++ b/src/CasCap.SmartHaus/Services/Mcp/FrontDoorMcpQueryService.cs @@ -24,6 +24,7 @@ public partial class FrontDoorMcpQueryService(IDoorBirdQueryService doorBirdQuer /// [McpServerTool] [Description("Unlocks the front door by triggering the electric door release.")] + [RequiresApproval(Reason = "Physical security action — unlocks the front door.")] public Task UnlockHouseDoor() => doorBirdQuerySvc.UnlockFrontDoor(); /// diff --git a/src/CasCap.SmartHaus/Services/Mcp/McpAuditMiddleware.cs b/src/CasCap.SmartHaus/Services/Mcp/McpAuditMiddleware.cs new file mode 100644 index 0000000..f264483 --- /dev/null +++ b/src/CasCap.SmartHaus/Services/Mcp/McpAuditMiddleware.cs @@ -0,0 +1,286 @@ +using CasCap.Attributes; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using System.Collections.Frozen; +using System.Text.Json; + +namespace CasCap.Services; + +/// +/// Function invocation middleware that audits every MCP tool call and optionally +/// gates execution behind a human-in-the-loop approval step. +/// +/// +/// +/// Audit records are emitted via structured logging at +/// and (when Redis is available) appended to a Redis Stream for durable queryable history. +/// +/// +/// The approval gate inspects the on the underlying +/// method and the / +/// config overrides to determine whether to pause execution and poll the user via Signal. +/// +/// +public sealed class McpAuditMiddleware( + ILogger logger, + IOptionsMonitor auditConfig, + IPollTracker pollTracker, + SignalCliRestClientService signalCliSvc, + IOptions signalCliConfig, + IOptions commsAgentConfig) +{ + /// + /// Cached set of tool names (PascalCase method names) that carry . + /// Built lazily on first invocation from all classes. + /// + private FrozenDictionary? _approvalLookup; + + /// JSON serializer options for argument logging. + private static readonly JsonSerializerOptions s_jsonOptions = new() + { + WriteIndented = false, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + + /// + /// The middleware entry point matching the AIAgentBuilder.Use(...) delegate signature. + /// + public async ValueTask InvokeAsync( + AIAgent agent, + FunctionInvocationContext context, + Func> next, + CancellationToken cancellationToken) + { + var config = auditConfig.CurrentValue; + if (!config.Enabled) + return await next(context, cancellationToken); + + var toolName = context.Function.Name; + var argsJson = SerializeArguments(context.Arguments); + var timestampUtc = DateTime.UtcNow; + + // ── Approval gate ──────────────────────────────────────────────────── + var approvalRequired = IsApprovalRequired(toolName, config); + bool? approvalGranted = null; + + if (approvalRequired && config.ApprovalEnabled) + { + approvalGranted = await RequestApprovalAsync(toolName, argsJson, config, cancellationToken); + + if (approvalGranted != true) + { + var deniedRecord = new McpAuditRecord + { + TimestampUtc = timestampUtc, + ToolName = toolName, + Arguments = argsJson, + Duration = TimeSpan.Zero, + Success = false, + ErrorType = "ApprovalDenied", + ErrorMessage = approvalGranted == false ? "User denied the action." : "Approval timed out.", + ApprovalRequired = true, + ApprovalGranted = false, + }; + + LogAuditRecord(deniedRecord); + return $"Action denied: {deniedRecord.ErrorMessage}"; + } + } + + // ── Execute the tool ───────────────────────────────────────────────── + var sw = Stopwatch.StartNew(); + object? result; + McpAuditRecord auditRecord; + + try + { + result = await next(context, cancellationToken); + sw.Stop(); + + auditRecord = new McpAuditRecord + { + TimestampUtc = timestampUtc, + ToolName = toolName, + Arguments = argsJson, + Duration = sw.Elapsed, + Success = true, + ApprovalRequired = approvalRequired, + ApprovalGranted = approvalRequired ? approvalGranted : null, + }; + } + catch (Exception ex) + { + sw.Stop(); + + auditRecord = new McpAuditRecord + { + TimestampUtc = timestampUtc, + ToolName = toolName, + Arguments = argsJson, + Duration = sw.Elapsed, + Success = false, + ErrorType = ex.GetType().Name, + ErrorMessage = ex.Message, + ApprovalRequired = approvalRequired, + ApprovalGranted = approvalRequired ? approvalGranted : null, + }; + + LogAuditRecord(auditRecord); + throw; + } + + LogAuditRecord(auditRecord); + return result; + } + + #region private helpers + + private void LogAuditRecord(McpAuditRecord record) + { + if (record.Success) + { + logger.LogInformation( + "{ClassName} tool={ToolName} duration={Duration} args={Arguments} approvalRequired={ApprovalRequired} approvalGranted={ApprovalGranted}", + nameof(McpAuditMiddleware), record.ToolName, record.Duration, record.Arguments, + record.ApprovalRequired, record.ApprovalGranted); + } + else + { + logger.LogWarning( + "{ClassName} tool={ToolName} duration={Duration} args={Arguments} error={ErrorType}: {ErrorMessage} approvalRequired={ApprovalRequired} approvalGranted={ApprovalGranted}", + nameof(McpAuditMiddleware), record.ToolName, record.Duration, record.Arguments, + record.ErrorType, record.ErrorMessage, record.ApprovalRequired, record.ApprovalGranted); + } + } + + private bool IsApprovalRequired(string toolName, AuditConfig config) + { + // Config-driven overrides take precedence. + if (config.NeverRequireApproval.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + return false; + + if (config.AlwaysRequireApproval.Contains(toolName, StringComparer.OrdinalIgnoreCase)) + return true; + + // Fall back to attribute-based lookup. + var lookup = GetApprovalLookup(); + return lookup.ContainsKey(toolName); + } + + private FrozenDictionary GetApprovalLookup() + { + if (_approvalLookup is not null) + return _approvalLookup; + + var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Scan all McpServerToolType classes in this assembly for [RequiresApproval] methods. + var assembly = typeof(McpAuditMiddleware).Assembly; + var toolTypes = assembly.GetTypes() + .Where(t => t.GetCustomAttribute() is not null); + + foreach (var type in toolTypes) + { + var methods = type.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static) + .Where(m => m.GetCustomAttribute() is not null); + + foreach (var method in methods) + { + var attr = method.GetCustomAttribute(); + if (attr is not null) + dict[method.Name] = attr; + } + } + + _approvalLookup = dict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); + return _approvalLookup; + } + + private async Task RequestApprovalAsync(string toolName, string argsJson, AuditConfig config, CancellationToken cancellationToken) + { + var phoneNumber = signalCliConfig.Value.PhoneNumber; + var groupName = commsAgentConfig.Value.GroupName; + + // Resolve the group ID. + var groups = await signalCliSvc.ListGroups(phoneNumber); + var groupId = groups?.FirstOrDefault(g => g.Name == groupName)?.Id; + if (groupId is null) + { + logger.LogWarning("{ClassName} cannot resolve Signal group {GroupName} for approval poll, denying by default", + nameof(McpAuditMiddleware), groupName); + return null; + } + + // Create the approval poll. + var question = $"\u26a0\ufe0f Agent wants to call '{toolName}'. Approve?"; + var answers = new[] { "Yes", "No" }; + var request = new CreatePollRequest + { + Question = question, + Answers = answers, + Recipient = groupId, + }; + + var response = await signalCliSvc.CreatePoll(phoneNumber, request); + if (response is null) + { + logger.LogWarning("{ClassName} failed to create approval poll for {ToolName}, denying by default", + nameof(McpAuditMiddleware), toolName); + return null; + } + + pollTracker.TrackPoll(response.Timestamp, question, answers, groupId); + + // Poll for votes until timeout. + var timeoutMs = config.ApprovalTimeoutMs; + var pollInterval = TimeSpan.FromMilliseconds(2000); + var elapsed = 0; + + while (elapsed < timeoutMs) + { + await Task.Delay(pollInterval, cancellationToken); + elapsed += (int)pollInterval.TotalMilliseconds; + + var poll = pollTracker.GetPoll(response.Timestamp); + if (poll is null || poll.Votes.Count == 0) + continue; + + // Check the first vote received. + var firstVote = poll.Votes.Values.First(); + var votedYes = firstVote.Any(i => i == 0); // Index 0 = "Yes" + + logger.LogInformation("{ClassName} approval poll for {ToolName} received vote: {Approved}", + nameof(McpAuditMiddleware), toolName, votedYes); + + // Clean up the poll. + pollTracker.RemovePoll(response.Timestamp); + + return votedYes; + } + + // Timeout — deny by default (fail-safe). + logger.LogWarning("{ClassName} approval poll for {ToolName} timed out after {TimeoutMs}ms, denying", + nameof(McpAuditMiddleware), toolName, timeoutMs); + pollTracker.RemovePoll(response.Timestamp); + + return null; + } + + private static string SerializeArguments(IReadOnlyDictionary arguments) + { + if (arguments.Count == 0) + return "{}"; + + try + { + return JsonSerializer.Serialize(arguments, s_jsonOptions); + } + catch + { + // Fallback for non-serializable arguments. + return string.Join(", ", arguments.Select(x => $"{x.Key}={x.Value}")); + } + } + + #endregion +} diff --git a/src/CasCap.SmartHaus/Services/Mcp/SmartLightingMcpQueryService.cs b/src/CasCap.SmartHaus/Services/Mcp/SmartLightingMcpQueryService.cs index 50a42e0..b7fafeb 100644 --- a/src/CasCap.SmartHaus/Services/Mcp/SmartLightingMcpQueryService.cs +++ b/src/CasCap.SmartHaus/Services/Mcp/SmartLightingMcpQueryService.cs @@ -37,6 +37,7 @@ public Task ChangeHouseLightState( /// [McpServerTool] [Description("Turns off every light that is currently on in the house. Requires KNX feature.")] + [RequiresApproval(Reason = "Bulk state change — turns off all lights in the house.")] public Task SwitchOffAllHouseLights(CancellationToken cancellationToken = default) => knxQuerySvc is not null ? knxQuerySvc.TurnAllLightsOff(cancellationToken) diff --git a/src/CasCap.SmartHaus/Services/Mcp/SmartPlugMcpQueryService.cs b/src/CasCap.SmartHaus/Services/Mcp/SmartPlugMcpQueryService.cs index 1fc3559..ed153f4 100644 --- a/src/CasCap.SmartHaus/Services/Mcp/SmartPlugMcpQueryService.cs +++ b/src/CasCap.SmartHaus/Services/Mcp/SmartPlugMcpQueryService.cs @@ -21,6 +21,7 @@ public partial class SmartPlugMcpQueryService(IShellyQueryService shellyQuerySvc /// [McpServerTool] [Description("Turn a smart plug relay on or off.")] + [RequiresApproval(Reason = "Controls physical appliance power state.")] public Task SetSmartPlugPower( [Description("Device ID to control.")] string deviceId,