feat(backend): wire WhiteGloveSealingPatternIds through /api/agent/config

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<string>) + 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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-04-21 10:27:54 +02:00
parent 0936132a00
commit 260d9afd8d
4 changed files with 109 additions and 2 deletions
@@ -0,0 +1,69 @@
using AutopilotMonitor.Shared.Models;
using Xunit;
namespace AutopilotMonitor.Functions.Tests
{
/// <summary>
/// Tests for <see cref="AdminConfiguration.GetWhiteGloveSealingPatternIds"/>. Unlike the
/// ModernDeployment helper, this feature is <b>off by default</b> — the fallback for
/// null / empty / invalid JSON must be an empty list so M3-compatible behaviour is
/// preserved until an operator opts in.
/// </summary>
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);
}
}
}
@@ -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"),
@@ -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;
@@ -307,6 +307,41 @@ namespace AutopilotMonitor.Shared.Models
}
}
// ===== WHITEGLOVE SEALING PATTERN IDS (Plan §M5, M4.4.4 / M4.4.5.e) =====
/// <summary>
/// JSON-serialized list of <see cref="ImeLogPattern"/> IDs whose match is re-emitted by
/// the V2 agent as a <c>WhiteGloveSealingPatternDetected</c> DecisionSignal (in addition
/// to the normal <c>ime_pattern_match</c> event). Example: <c>"[\"wg-seal-1\",\"wg-seal-2\"]"</c>.
/// <para>
/// 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).
/// </para>
/// </summary>
public string WhiteGloveSealingPatternIdsJson { get; set; } = default!;
/// <summary>
/// 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).
/// </summary>
public List<string> GetWhiteGloveSealingPatternIds()
{
if (string.IsNullOrWhiteSpace(WhiteGloveSealingPatternIdsJson))
return new List<string>();
try
{
return JsonSerializer.Deserialize<List<string>>(WhiteGloveSealingPatternIdsJson,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? new List<string>();
}
catch
{
return new List<string>();
}
}
/// <summary>
/// Whether vulnerability correlation is globally enabled.
/// When false, agents still collect inventory but backend skips correlation.