From 260d9afd8dfcecab8ce45df940b1623b4c1a558e Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Tue, 21 Apr 2026 10:27:54 +0200 Subject: [PATCH] feat(backend): wire WhiteGloveSealingPatternIds through /api/agent/config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The V2-agent AgentConfigResponse schema has carried four fields the backend was expected to populate (Plan §M5): LatestAgentSha256, LatestAgentExeSha256, AllowAgentDowngrade, and WhiteGloveSealingPatternIds. The first three were already wired through adminConfig; the last one was declared on the DTO but never populated from storage — V2 agents always received an empty list, effectively leaving the sealing-pattern emission path dead. Adds the missing plumbing: - AdminConfiguration.WhiteGloveSealingPatternIdsJson (string, JSON-serialised List) + GetWhiteGloveSealingPatternIds() helper. Defaults to empty for null/whitespace/invalid JSON so the feature stays off by default (M3-compatible, no regression on tenants that haven't opted in). Global-only, no per-tenant override (plan §M5 M4.4.5.e decision). - TableConfigRepository Store + Map entries for the new column (feedback_table_storage_serialization — new model properties MUST round-trip through the table mapping or the field silently drops on save/load). - GetAgentConfigFunction populates WhiteGloveSealingPatternIds from adminConfig.GetWhiteGloveSealingPatternIds(). ConfigVersion bumped 25 → 26 as a semantic-change marker. 5 new tests mirror the HarmlessEventIds pattern (null / whitespace / invalid JSON / valid round-trip / explicit empty array). 605/605 backend tests green. Agent-side (M4.4.4 / M4.4.5.e): the V2 agent's OnPatternMatched hook already reads WhiteGloveSealingPatternIds from the cached config and re-emits matching IME pattern hits as WhiteGloveSealingPatternDetected DecisionSignals. With this commit the data path is end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...urationWhiteGloveSealingPatternIdsTests.cs | 69 +++++++++++++++++++ .../TableStorage/TableConfigRepository.cs | 2 + .../Config/GetAgentConfigFunction.cs | 5 +- .../Models/Config/AdminConfiguration.cs | 35 ++++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs diff --git a/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs new file mode 100644 index 00000000..da460942 --- /dev/null +++ b/src/Backend/AutopilotMonitor.Functions.Tests/AdminConfigurationWhiteGloveSealingPatternIdsTests.cs @@ -0,0 +1,69 @@ +using AutopilotMonitor.Shared.Models; +using Xunit; + +namespace AutopilotMonitor.Functions.Tests +{ + /// + /// Tests for . Unlike the + /// ModernDeployment helper, this feature is off by default — the fallback for + /// null / empty / invalid JSON must be an empty list so M3-compatible behaviour is + /// preserved until an operator opts in. + /// + public sealed class AdminConfigurationWhiteGloveSealingPatternIdsTests + { + [Fact] + public void GetWhiteGloveSealingPatternIds_NullJson_ReturnsEmpty() + { + var cfg = new AdminConfiguration(); + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_WhitespaceJson_ReturnsEmpty() + { + var cfg = new AdminConfiguration { WhiteGloveSealingPatternIdsJson = " " }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_InvalidJson_ReturnsEmpty() + { + var cfg = new AdminConfiguration { WhiteGloveSealingPatternIdsJson = "not-json" }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_ValidJson_RoundTrips() + { + var cfg = new AdminConfiguration + { + WhiteGloveSealingPatternIdsJson = "[\"wg-seal-1\",\"wg-seal-2\",\"wg-seal-3\"]" + }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Equal(new[] { "wg-seal-1", "wg-seal-2", "wg-seal-3" }, ids); + } + + [Fact] + public void GetWhiteGloveSealingPatternIds_EmptyArray_ReturnsEmptyList() + { + // Explicit empty array means "feature opted-in but no patterns yet" — same + // observable behaviour as the default but distinguishable in storage. + var cfg = new AdminConfiguration { WhiteGloveSealingPatternIdsJson = "[]" }; + + var ids = cfg.GetWhiteGloveSealingPatternIds(); + + Assert.Empty(ids); + } + } +} diff --git a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs index f70f3d03..8d4baba4 100644 --- a/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs +++ b/src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableConfigRepository.cs @@ -541,6 +541,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage { "MaintenanceBlockDurationHours", config.MaintenanceBlockDurationHours }, { "DiagnosticsGlobalLogPathsJson", config.DiagnosticsGlobalLogPathsJson }, { "ModernDeploymentHarmlessEventIdsJson", config.ModernDeploymentHarmlessEventIdsJson ?? string.Empty }, + { "WhiteGloveSealingPatternIdsJson", config.WhiteGloveSealingPatternIdsJson ?? string.Empty }, { "NvdApiKey", config.NvdApiKey }, { "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled }, { "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc }, @@ -593,6 +594,7 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage MaintenanceBlockDurationHours = entity.GetInt32("MaintenanceBlockDurationHours") ?? 12, DiagnosticsGlobalLogPathsJson = entity.GetString("DiagnosticsGlobalLogPathsJson"), ModernDeploymentHarmlessEventIdsJson = entity.GetString("ModernDeploymentHarmlessEventIdsJson"), + WhiteGloveSealingPatternIdsJson = entity.GetString("WhiteGloveSealingPatternIdsJson"), NvdApiKey = entity.GetString("NvdApiKey"), VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true, VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"), diff --git a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs index 36540dd5..8a027f6a 100644 --- a/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs +++ b/src/Backend/AutopilotMonitor.Functions/Functions/Config/GetAgentConfigFunction.cs @@ -141,7 +141,7 @@ namespace AutopilotMonitor.Functions.Functions.Config var response = req.CreateResponse(HttpStatusCode.OK); await response.WriteAsJsonAsync(new AgentConfigResponse { - ConfigVersion = 25, // IntegrityBypassAnalyzer flag + ConfigVersion = 26, // WhiteGloveSealingPatternIds wired from AdminConfiguration UploadIntervalSeconds = 10, SelfDestructOnComplete = tenantConfig.SelfDestructOnComplete ?? true, KeepLogFile = tenantConfig.KeepLogFile ?? false, @@ -176,7 +176,8 @@ namespace AutopilotMonitor.Functions.Functions.Config SendTraceEvents = tenantConfig.SendTraceEvents, UnrestrictedMode = tenantConfig.UnrestrictedModeEnabled && tenantConfig.UnrestrictedMode, GatherRules = gatherRules, - ImeLogPatterns = imeLogPatterns + ImeLogPatterns = imeLogPatterns, + WhiteGloveSealingPatternIds = adminConfig.GetWhiteGloveSealingPatternIds(), }); return response; diff --git a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs index 97da1224..69adc71f 100644 --- a/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs +++ b/src/Shared/AutopilotMonitor.Shared/Models/Config/AdminConfiguration.cs @@ -307,6 +307,41 @@ namespace AutopilotMonitor.Shared.Models } } + // ===== WHITEGLOVE SEALING PATTERN IDS (Plan §M5, M4.4.4 / M4.4.5.e) ===== + + /// + /// JSON-serialized list of IDs whose match is re-emitted by + /// the V2 agent as a WhiteGloveSealingPatternDetected DecisionSignal (in addition + /// to the normal ime_pattern_match event). Example: "[\"wg-seal-1\",\"wg-seal-2\"]". + /// + /// Default null/empty = feature off (M3-compatible, no regression risk). Only IDs in + /// this list count as sealing signals; other IME pattern matches follow the regular + /// event path. Global-only — no per-tenant override (plan §M5 M4.4.5.e decision). + /// + /// + public string WhiteGloveSealingPatternIdsJson { get; set; } = default!; + + /// + /// Returns the deserialized list of WhiteGlove sealing pattern IDs. Falls back to an + /// empty list for null/empty/invalid JSON so the feature stays off by default + /// (M3-compatible). + /// + public List GetWhiteGloveSealingPatternIds() + { + if (string.IsNullOrWhiteSpace(WhiteGloveSealingPatternIdsJson)) + return new List(); + try + { + return JsonSerializer.Deserialize>(WhiteGloveSealingPatternIdsJson, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) + ?? new List(); + } + catch + { + return new List(); + } + } + /// /// Whether vulnerability correlation is globally enabled. /// When false, agents still collect inventory but backend skips correlation.