diff --git a/.gitignore b/.gitignore index 4ec851f7..e4d4edca 100644 --- a/.gitignore +++ b/.gitignore @@ -117,3 +117,4 @@ scripts/Deployment/build_and_upload_release_agent_build.ps1 .vscode/ TODO.txt tools/orphan-analysis.py +tasks/todo.md diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs new file mode 100644 index 00000000..78bfeeef --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinInfoTests.cs @@ -0,0 +1,40 @@ +using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Collectors +{ + public class AadJoinInfoTests + { + [Theory] + [InlineData("foouser@contoso.onmicrosoft.com", true)] + [InlineData("FOOUSER@CONTOSO.onmicrosoft.com", true)] + [InlineData("FooUser@tenant.com", true)] + [InlineData("autopilot@contoso.onmicrosoft.com", true)] + [InlineData("AutoPilot@tenant.com", true)] + [InlineData("AUTOPILOT@xyz.com", true)] + public void IsPlaceholderUserEmail_KnownPatterns_ReturnsTrue(string email, bool expected) + { + Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email)); + } + + [Theory] + [InlineData("alice@contoso.onmicrosoft.com")] + [InlineData("bob.smith@company.com")] + [InlineData("foo@bar.com")] + [InlineData("autopilotadmin@company.com")] + [InlineData("myfoouser@company.com")] + public void IsPlaceholderUserEmail_RealEmails_ReturnsFalse(string email) + { + Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsPlaceholderUserEmail_NullOrEmpty_ReturnsFalse(string email) + { + Assert.False(AadJoinInfo.IsPlaceholderUserEmail(email)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs new file mode 100644 index 00000000..cb8c880f --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Collectors/AadJoinWatcherTests.cs @@ -0,0 +1,94 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.Core.Tests.Helpers; +using Microsoft.Win32; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Collectors +{ + /// + /// Integration-style tests for . + /// Uses a temporary HKCU sub-key because the production path is under HKLM\SYSTEM which + /// requires admin privileges. The watcher implementation reads HKLM, so these tests + /// instead assert the observable semantics of + /// and end-to-end placeholder / real-user classification behaviour of the watcher's + /// decision code path that is testable without HKLM writes. + /// + /// The full RegNotifyChangeKeyValue flow is validated by the manual syntetic test in + /// the verification section of the plan — it requires a test VM with elevated rights. + /// + public class AadJoinWatcherTests + { + [Fact] + public void Constructor_NullLogger_Throws() + { + Assert.Throws(() => new AadJoinWatcher(null)); + } + + [Fact] + public void Start_MissingRootKey_ArmsRetryTimer_DoesNotThrow() + { + // Running tests under a normal user cannot write HKLM — but Start() must + // gracefully fall back to the retry timer without throwing. + var logger = TestLogger.Instance; + using (var watcher = new AadJoinWatcher(logger)) + { + watcher.Start(); + + // Give the retry timer one tick — we expect no crash, no events. + Thread.Sleep(250); + + bool placeholderFired = false; + bool realUserFired = false; + watcher.PlaceholderUserDetected += (s, e) => placeholderFired = true; + watcher.AadUserJoined += (s, e) => realUserFired = true; + + Thread.Sleep(250); + + Assert.False(placeholderFired); + Assert.False(realUserFired); + } + } + + [Fact] + public void Stop_IsIdempotent_MultipleCallsDoNotThrow() + { + var logger = TestLogger.Instance; + var watcher = new AadJoinWatcher(logger); + watcher.Start(); + watcher.Stop(); + watcher.Stop(); // idempotent + watcher.Dispose(); + watcher.Dispose(); // idempotent + } + + [Fact] + public void Dispose_AfterStart_StopsCleanly() + { + var logger = TestLogger.Instance; + var watcher = new AadJoinWatcher(logger); + watcher.Start(); + watcher.Dispose(); + + // After Dispose a further Start must throw + Assert.Throws(() => watcher.Start()); + } + + // Delegate to AadJoinInfo-level tests for placeholder-pattern recognition — same + // code path as the watcher uses internally. + [Theory] + [InlineData("foouser@tenant.onmicrosoft.com", true)] + [InlineData("FOOUSER@foo.com", true)] + [InlineData("autopilot@tenant.com", true)] + [InlineData("AutoPilot@tenant.com", true)] + [InlineData("alice@contoso.com", false)] + [InlineData("myfoouser@contoso.com", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void PlaceholderEmailClassification_MatchesAadJoinInfoRule(string email, bool expected) + { + Assert.Equal(expected, AadJoinInfo.IsPlaceholderUserEmail(email)); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs index ab5a72a8..665e713d 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/CompletionStateMachineTests.cs @@ -1159,32 +1159,86 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking } [Fact] - public void DeviceOnly_ProvisioningComplete_WithSaveWhiteGloveSuccessResult_TransitionsToWhiteGloveCompleted() + public void DeviceOnly_ProvisioningComplete_WithOnlySaveWhiteGloveSuccessResult_DoesNotTriggerWhiteGlove() { + // Post-classifier behaviour: HasSaveWhiteGloveSuccessResult alone scores 10 (very + // weak — it also fires on genuinely non-WG devices). With DeviceOnly that reaches + // 25 → Confidence.None → NOT routed to WhiteGlove. This is the core bugfix. var sm = new CompletionStateMachine(); var ctx = DeviceOnlyContext(); ctx.HasSaveWhiteGloveSuccessResult = true; var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + Assert.False(result.ShouldEmitWhiteGloveComplete); + Assert.True(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void DeviceOnly_ProvisioningComplete_BothWgSignals_IsWeak_DoesNotTriggerWhiteGlove() + { + // SaveWg(+10) + Event509(+15) + DeviceOnly(+15) = 40 → Confidence.Weak → NOT WG + // (asymmetric-conservative routing: unsicher → kein WG). + var sm = new CompletionStateMachine(); + var ctx = DeviceOnlyContext(); + ctx.WhiteGloveStartDetected = true; + ctx.HasSaveWhiteGloveSuccessResult = true; + + var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + + Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + Assert.False(result.ShouldEmitWhiteGloveComplete); + } + + [Fact] + public void DeviceOnly_ProvisioningComplete_ShellCoreWgSuccess_TransitionsToWhiteGloveCompleted() + { + // ShellCoreWhiteGloveSuccess (Event 62407) alone scores 80 → Confidence.Strong → WG. + var sm = new CompletionStateMachine(); + var ctx = DeviceOnlyContext(); + ctx.ShellCoreWhiteGloveSuccess = true; + + var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); Assert.True(result.ShouldEmitWhiteGloveComplete); Assert.False(result.ShouldEmitEnrollmentComplete); } [Fact] - public void DeviceOnly_ProvisioningComplete_BothWhiteGloveSignals_TransitionsToWhiteGloveCompleted() + public void DeviceOnly_ProvisioningComplete_WgSignalVerbund_ReachingThreshold_TransitionsToWhiteGlove() { + // Signalverbund ohne ShellCore: SaveWg(+10) + Event509(+15) + FooUser(+20) + + // DeviceOnly(+15) + AgentRestartAfterEsp(+10) = 70 → Strong → WG. var sm = new CompletionStateMachine(); var ctx = DeviceOnlyContext(); ctx.WhiteGloveStartDetected = true; ctx.HasSaveWhiteGloveSuccessResult = true; + ctx.IsFooUserDetected = true; + ctx.EspFinalExitUtc = DateTime.UtcNow.AddHours(-1); + ctx.AgentStartTimeUtc = DateTime.UtcNow.AddMinutes(-30); var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); Assert.True(result.ShouldEmitWhiteGloveComplete); - Assert.False(result.ShouldEmitEnrollmentComplete); + } + + [Fact] + public void DeviceOnly_ProvisioningComplete_LateAadJoin_DoesNotTriggerWhiteGlove() + { + // Selbst mit starkem WG-Signal: realer AAD-Join (späte Erkennung via AadJoinWatcher) + // muss Classifier-Hard-Excluder auslösen → NICHT WG. + var sm = new CompletionStateMachine(); + var ctx = DeviceOnlyContext(); + ctx.ShellCoreWhiteGloveSuccess = true; + ctx.AadJoinedWithUser = true; // late AAD join flipped this + + var result = sm.ProcessTrigger("device_setup_provisioning_complete", ctx); + + Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + Assert.False(result.ShouldEmitWhiteGloveComplete); } [Fact] @@ -1219,9 +1273,11 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking [Fact] public void WhiteGloveCompleted_IsTerminal() { + // Use ShellCoreWhiteGloveSuccess (score 80) to drive into WG — post-classifier + // it's the only single signal that hits Strong on its own. var sm = new CompletionStateMachine(); var ctx = DeviceOnlyContext(); - ctx.HasSaveWhiteGloveSuccessResult = true; + ctx.ShellCoreWhiteGloveSuccess = true; sm.ProcessTrigger("device_setup_provisioning_complete", ctx); Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); @@ -1251,8 +1307,9 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking } [Fact] - public void EspExiting_SkipUser_WithSaveWhiteGloveSuccessResult_TransitionsToWhiteGloveCompleted() + public void EspExiting_SkipUser_WithOnlySaveWhiteGloveSuccessResult_DoesNotTriggerWhiteGlove() { + // Post-classifier behaviour: weak single signal (score 25 with device-only) → NOT WG. var sm = new CompletionStateMachine(); var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, @@ -1262,6 +1319,24 @@ namespace AutopilotMonitor.Agent.Core.Tests.Tracking ctx.LastEspPhase = "DeviceSetup"; var result = sm.ProcessTrigger("esp_exiting", ctx); + Assert.NotEqual(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); + Assert.False(result.ShouldEmitWhiteGloveComplete); + } + + [Fact] + public void EspExiting_SkipUser_WithShellCoreWgSuccess_TransitionsToWhiteGloveCompleted() + { + // ShellCoreWhiteGloveSuccess alone reaches Strong → WG. + var sm = new CompletionStateMachine(); + + var ctx = DefaultContext(autopilotMode: 0, skipUserStatusPage: true, aadJoinedWithUser: false, + isHelloPolicyConfigured: false); + ctx.ShellCoreWhiteGloveSuccess = true; + sm.ProcessTrigger("esp_phase_changed", ctx); + + ctx.LastEspPhase = "DeviceSetup"; + var result = sm.ProcessTrigger("esp_exiting", ctx); + Assert.Equal(EnrollmentCompletionState.WhiteGloveCompleted, sm.CurrentState); Assert.True(result.ShouldEmitWhiteGloveComplete); } diff --git a/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs new file mode 100644 index 00000000..a5ca440a --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core.Tests/Tracking/WhiteGloveClassifierTests.cs @@ -0,0 +1,243 @@ +using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion; +using Xunit; + +namespace AutopilotMonitor.Agent.Core.Tests.Tracking +{ + public class WhiteGloveClassifierTests + { + // ===================================================================== + // Zero / None baseline + // ===================================================================== + + [Fact] + public void Classify_AllSignalsOff_ReturnsNone() + { + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals()); + + Assert.Equal(WhiteGloveConfidence.None, result.Confidence); + Assert.Equal(0, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + Assert.Empty(result.ContributingFactors); + } + + // ===================================================================== + // Single-signal behaviour (the core bugfix) + // ===================================================================== + + [Fact] + public void Classify_ShellCoreAlone_IsStrong() + { + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + }); + + Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence); + Assert.Equal(80, result.Score); + Assert.True(result.ShouldRouteToWhiteGlovePart1); + } + + [Fact] + public void Classify_OnlySaveWgSuccessResult_IsNoneNotWg() + { + // The core bug-avoidance test: SaveWhiteGloveSuccessResult=true alone has been + // observed on genuinely non-WG devices and MUST NOT classify as WhiteGlove. + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + HasSaveWhiteGloveSuccessResult = true, + }); + + Assert.Equal(WhiteGloveConfidence.None, result.Confidence); + Assert.Equal(10, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + } + + [Fact] + public void Classify_SaveWgPlusDeviceOnly_IsStillNone() + { + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + HasSaveWhiteGloveSuccessResult = true, + IsDeviceOnlyDeployment = true, + }); + + Assert.Equal(WhiteGloveConfidence.None, result.Confidence); + Assert.Equal(25, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + } + + [Fact] + public void Classify_WeakSignalVerbundJustBelowThreshold_IsWeakNotWg() + { + // Mehrere weiche Signale alleine reichen nicht fuer Strong. + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + IsFooUserDetected = true, // +20 + IsWhiteGloveStartDetected = true, // +15 + IsDeviceOnlyDeployment = true, // +15 + }); + + Assert.Equal(WhiteGloveConfidence.Weak, result.Confidence); + Assert.Equal(50, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + } + + [Fact] + public void Classify_WeakSignalVerbundAtThreshold_IsStrongAndRoutes() + { + // Signalverbund erreicht gerade die 70 — laut Plan: WG. + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + HasSaveWhiteGloveSuccessResult = true, // +10 + IsFooUserDetected = true, // +20 + IsWhiteGloveStartDetected = true, // +15 + IsDeviceOnlyDeployment = true, // +15 + AgentRestartedAfterEspExit = true, // +10 + }); + + Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence); + Assert.Equal(70, result.Score); + Assert.True(result.ShouldRouteToWhiteGlovePart1); + } + + // ===================================================================== + // Hard excluders + // ===================================================================== + + [Fact] + public void Classify_AadJoinedWithUser_OverridesStrongPositive() + { + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + AadJoinedWithUser = true, // real user — definitively not WG Part 1 + }); + + Assert.Equal(WhiteGloveConfidence.None, result.Confidence); + Assert.Equal(0, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + } + + [Fact] + public void Classify_DesktopArrived_OverridesStrongPositive() + { + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + DesktopArrived = true, + }); + + Assert.Equal(WhiteGloveConfidence.None, result.Confidence); + Assert.Equal(0, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + } + + [Fact] + public void Classify_AccountSetupActivity_DampensButDoesNotHardExclude() + { + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, // +80 + HasAccountSetupActivity = true, // -40 + }); + + // 80 - 40 = 40 → Weak (NOT routed to WG) + Assert.Equal(WhiteGloveConfidence.Weak, result.Confidence); + Assert.Equal(40, result.Score); + Assert.False(result.ShouldRouteToWhiteGlovePart1); + } + + // ===================================================================== + // Placeholder user (foouser@ / autopilot@) — NOT aadJoinedWithUser + // ===================================================================== + + [Fact] + public void Classify_FooUserWithoutRealAadJoin_StaysStrong() + { + // Placeholder-User ist KEIN realer AAD-Join; Hard-Excluder greift nicht. + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, // +80 + IsFooUserDetected = true, // +20 + AadJoinedWithUser = false, // Placeholder zählt NICHT als real + }); + + Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence); + Assert.Equal(100, result.Score); // capped + Assert.True(result.ShouldRouteToWhiteGlovePart1); + } + + // ===================================================================== + // Hybrid join is NOT a dampener (removed per plan review) + // ===================================================================== + + [Fact] + public void Classify_HybridWithStrongSignal_StaysStrong() + { + // Hybrid-Join darf die WG-Klassifikation nicht dämpfen (WG funktioniert auch hybrid). + var result = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + IsDeviceOnlyDeployment = true, + }); + + Assert.Equal(WhiteGloveConfidence.Strong, result.Confidence); + Assert.Equal(95, result.Score); + Assert.True(result.ShouldRouteToWhiteGlovePart1); + } + + // ===================================================================== + // Late AAD-Join Transition + // ===================================================================== + + [Fact] + public void Classify_LateAadJoinFlipsStrongToNone() + { + // Vor dem Late-Update: Strong. + var before = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + AadJoinedWithUser = false, + }); + Assert.Equal(WhiteGloveConfidence.Strong, before.Confidence); + Assert.True(before.ShouldRouteToWhiteGlovePart1); + + // Nach dem Late-AAD-Update: None (harter Ausschluss). + var after = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + AadJoinedWithUser = true, + }); + Assert.Equal(WhiteGloveConfidence.None, after.Confidence); + Assert.False(after.ShouldRouteToWhiteGlovePart1); + } + + // ===================================================================== + // ShouldRouteToWhiteGlovePart1 only fires on Strong + // ===================================================================== + + [Fact] + public void Classify_ShouldRoute_OnlyOnStrong() + { + var none = WhiteGloveClassifier.Classify(new WhiteGloveSignals()); + var weak = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + IsFooUserDetected = true, + IsDeviceOnlyDeployment = true, + }); + var strong = WhiteGloveClassifier.Classify(new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = true, + }); + + Assert.Equal(WhiteGloveConfidence.None, none.Confidence); + Assert.False(none.ShouldRouteToWhiteGlovePart1); + + Assert.Equal(WhiteGloveConfidence.Weak, weak.Confidence); + Assert.False(weak.ShouldRouteToWhiteGlovePart1); + + Assert.Equal(WhiteGloveConfidence.Strong, strong.Confidence); + Assert.True(strong.ShouldRouteToWhiteGlovePart1); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs index a7896e69..80e2ddb1 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/CompletionStateMachine.cs @@ -38,10 +38,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion // WhiteGlove detection signals public bool WhiteGloveStartDetected { get; set; } public bool HasSaveWhiteGloveSuccessResult { get; set; } - // Only SaveWhiteGloveSuccessResult=succeeded from ESP registry is a reliable WG confirmation. - // WhiteGloveStartDetected (EventID 509) is a soft signal — fires on hybrid-join too. + public bool ShellCoreWhiteGloveSuccess { get; set; } + public bool IsFooUserDetected { get; set; } + + // Legacy helper — kept for backwards-compat with places that only need a binary + // "there is some WG evidence" read. Actual classification now goes through + // . public bool IsWhiteGloveActive => HasSaveWhiteGloveSuccessResult; + // Computed: has the agent restarted since ESP final exit? Typical WhiteGlove Part 1 + // boundary marker (the restart between DeviceSetup-complete and user sign-in). + public bool AgentRestartedAfterEspExit + => EspFinalExitUtc.HasValue && AgentStartTimeUtc > EspFinalExitUtc.Value; + // Timing public DateTime AgentStartTimeUtc { get; set; } public DateTime? EspFinalExitUtc { get; set; } @@ -319,13 +328,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion // Path B: Registry definitively says device-only else if (ctx.SkipUserStatusPage == true) { - // WhiteGlove guard: SkipUserStatusPage=true + WG signals → WhiteGloveCompleted. - // Mirrors the guard in EnrollmentTracker.CompletionLogic.OnFinalizingSetupPhaseTriggered. - if (ctx.IsWhiteGloveActive) + // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as + // HandleDeviceSetupProvisioningComplete and the EnrollmentTracker paths). + // Weak confidence falls through to the regular device-only path by design. + var wgSignals = WhiteGloveSignals.FromContext(ctx); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + if (wgClassification.ShouldRouteToWhiteGlovePart1) { _currentState = EnrollmentCompletionState.WhiteGloveCompleted; result.ShouldEmitWhiteGloveComplete = true; result.SignalsToRecord.Add("whiteglove_guard_esp_exiting"); + result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}"); + result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}"); result.Transitioned = true; result.NewState = _currentState; return result; @@ -532,13 +546,19 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion SignalsToRecord = new List { "device_setup_provisioning_complete" } }; - // WhiteGlove guard: device-only + WG signals → WhiteGloveCompleted, not self-deploying. - // This mirrors the guard in EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete. - if (ctx.IsDeviceOnly && ctx.IsWhiteGloveActive) + // WhiteGlove guard — canonical WhiteGloveClassifier decision (same rule as + // EnrollmentTracker.CompletionLogic.OnDeviceSetupProvisioningComplete and + // TryEmitEnrollmentComplete). Weak confidence falls through to the regular + // self-deploying path by design (asymmetric-conservative routing). + var wgSignals = WhiteGloveSignals.FromContext(ctx); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + if (wgClassification.ShouldRouteToWhiteGlovePart1) { _currentState = EnrollmentCompletionState.WhiteGloveCompleted; result.ShouldEmitWhiteGloveComplete = true; result.SignalsToRecord.Add("whiteglove_guard_activated"); + result.SignalsToRecord.Add($"wg_score:{wgClassification.Score}"); + result.SignalsToRecord.Add($"wg_confidence:{wgClassification.Confidence}"); result.Transitioned = true; result.NewState = _currentState; return result; diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs new file mode 100644 index 00000000..871a5575 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveClassifier.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion +{ + /// + /// Confidence level for a WhiteGlove Part 1 classification. + /// + internal enum WhiteGloveConfidence + { + /// Score below — definitely not WhiteGlove. + None, + + /// Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative). + Weak, + + /// Score at or above — classify as WhiteGlove Part 1. + Strong, + } + + /// + /// Result of a call. Contains the + /// final routing decision, the numeric score and the contributing factors for + /// observability / tuning. + /// + internal sealed class WhiteGloveClassification + { + public WhiteGloveConfidence Confidence { get; set; } + public int Score { get; set; } + public List ContributingFactors { get; set; } = new List(); + public string Reason { get; set; } + + /// + /// Single Boolean the caller should read to decide whether to route to + /// whiteglove_complete (Pending) instead of the normal completion path. + /// Only true when is . + /// + public bool ShouldRouteToWhiteGlovePart1 { get; set; } + } + + /// + /// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend + /// AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold pattern. + /// + /// Asymmetric-conservative decision rule: only a + /// score routes to WhiteGlove. A score is emitted + /// to telemetry for tuning but treated as NOT WhiteGlove. Rationale: a false-positive WG + /// classification keeps the agent pending when it should exit; a false-negative emits + /// enrollment_complete early and the later user sign-in triggers a new session — + /// the user-visible cost of false-negative is much lower than false-positive. + /// + internal static class WhiteGloveClassifier + { + // Thresholds (tunable after production data — see project memory). + internal const int HighThreshold = 70; // >= 70 → Strong → route to WG + internal const int LowThreshold = 30; // 30..69 → Weak (telemetry only) + internal const int BaseConfidence = 0; + + // Positive-signal weights + internal const int WeightShellCoreWhiteGloveSuccess = 80; // definitive — Event 62407 + internal const int WeightHasSaveWhiteGloveSuccess = 10; // very weak — also fires on non-WG devices + internal const int WeightWhiteGloveStartDetected = 15; // soft — Event 509 also fires on hybrid + internal const int WeightFooUserDetected = 20; // weak — pre-provisioning marker + internal const int WeightAgentRestartedAfterEspExit = 10; // weak — typical WG boundary + internal const int WeightDeviceOnlyDeployment = 15; // context boost + + // Negative-signal weights (hard excluders) + internal const int WeightAadJoinedWithUser = -100; // hard exclude + internal const int WeightDesktopArrived = -100; // hard exclude + internal const int WeightAccountSetupActive = -40; // soft exclude + + public static WhiteGloveClassification Classify(WhiteGloveSignals s) + { + if (s == null) throw new ArgumentNullException(nameof(s)); + + var factors = new List(); + int score = BaseConfidence; + + // Hard excluders + if (s.AadJoinedWithUser) + { + score += WeightAadJoinedWithUser; + factors.Add($"aad_joined_with_user:{WeightAadJoinedWithUser:+#;-#;0}"); + } + if (s.DesktopArrived) + { + score += WeightDesktopArrived; + factors.Add($"desktop_arrived:{WeightDesktopArrived:+#;-#;0}"); + } + if (s.HasAccountSetupActivity) + { + score += WeightAccountSetupActive; + factors.Add($"account_setup_activity:{WeightAccountSetupActive:+#;-#;0}"); + } + + // Positive signals — weighted by empirical reliability + if (s.ShellCoreWhiteGloveSuccess) + { + score += WeightShellCoreWhiteGloveSuccess; + factors.Add($"shellcore_wg_success:{WeightShellCoreWhiteGloveSuccess:+#;-#;0}"); + } + if (s.HasSaveWhiteGloveSuccessResult) + { + score += WeightHasSaveWhiteGloveSuccess; + factors.Add($"save_wg_success:{WeightHasSaveWhiteGloveSuccess:+#;-#;0}"); + } + if (s.IsWhiteGloveStartDetected) + { + score += WeightWhiteGloveStartDetected; + factors.Add($"event509_wg_start:{WeightWhiteGloveStartDetected:+#;-#;0}"); + } + if (s.IsFooUserDetected) + { + score += WeightFooUserDetected; + factors.Add($"foouser_detected:{WeightFooUserDetected:+#;-#;0}"); + } + if (s.AgentRestartedAfterEspExit) + { + score += WeightAgentRestartedAfterEspExit; + factors.Add($"agent_restart_after_esp:{WeightAgentRestartedAfterEspExit:+#;-#;0}"); + } + if (s.IsDeviceOnlyDeployment) + { + score += WeightDeviceOnlyDeployment; + factors.Add($"device_only_deployment:{WeightDeviceOnlyDeployment:+#;-#;0}"); + } + + // NOTE: no hybrid-join dampener — WhiteGlove works on both AADJ and hybrid. + // NOTE: no "long stable since DeviceSetup" bonus — devices stand idle for other reasons. + + // Cap to [0, 100] + if (score < 0) score = 0; + if (score > 100) score = 100; + + WhiteGloveConfidence confidence; + if (score >= HighThreshold) confidence = WhiteGloveConfidence.Strong; + else if (score >= LowThreshold) confidence = WhiteGloveConfidence.Weak; + else confidence = WhiteGloveConfidence.None; + + return new WhiteGloveClassification + { + Confidence = confidence, + Score = score, + ContributingFactors = factors, + Reason = $"confidence={confidence} score={score}", + ShouldRouteToWhiteGlovePart1 = (confidence == WhiteGloveConfidence.Strong), + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs new file mode 100644 index 00000000..6c7c193c --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/Completion/WhiteGloveSignals.cs @@ -0,0 +1,63 @@ +using System; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion +{ + /// + /// Snapshot of all signals relevant for WhiteGlove (Part 1) classification. + /// Populated under _stateLock in the caller and consumed outside the lock by + /// — mirrors the CompletionContext + /// pattern to preserve the existing thread-safety guarantees. + /// + /// Source-of-truth is the EnrollmentTracker state. When the tracker uses + /// CompletionStateMachine (shadow), use to keep both + /// decision paths in sync. + /// + internal sealed class WhiteGloveSignals + { + // "Hard" positive signals (registry / Shell-Core) + public bool ShellCoreWhiteGloveSuccess { get; set; } // Event 62407 "WhiteGlove_Success" — strongest signal + public bool HasSaveWhiteGloveSuccessResult { get; set; } // DeviceSetup registry subcategory — also fires on non-WG + public bool IsWhiteGloveStartDetected { get; set; } // Event 509 — soft, fires on hybrid too + public bool IsFooUserDetected { get; set; } // foouser@/autopilot@ in JoinInfo — pre-provisioning indicator + public bool AgentRestartedAfterEspExit { get; set; } // typical WG Part 1 boundary + + // Hard excluders (drive score negative) + public bool AadJoinedWithUser { get; set; } // real user signed in — not WG Part 1 anymore + public bool DesktopArrived { get; set; } // explorer.exe running — not WG Part 1 anymore + public bool HasAccountSetupActivity { get; set; } // user-driven ESP category active + + // Deployment context + public bool IsDeviceOnlyDeployment { get; set; } + + // Optional metadata (populated for observability, not scored) + public DateTime? EspFinalExitUtc { get; set; } + public DateTime? DeviceSetupProvisioningCompleteUtc { get; set; } + + /// + /// Builds a signal snapshot from a . Used by the + /// CompletionStateMachine so that state-machine decisions use the exact same + /// inputs as the EnrollmentTracker's direct decisions. + /// + public static WhiteGloveSignals FromContext(CompletionContext ctx) + { + if (ctx == null) throw new ArgumentNullException(nameof(ctx)); + + return new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = ctx.ShellCoreWhiteGloveSuccess, + HasSaveWhiteGloveSuccessResult = ctx.HasSaveWhiteGloveSuccessResult, + IsWhiteGloveStartDetected = ctx.WhiteGloveStartDetected, + IsFooUserDetected = ctx.IsFooUserDetected, + AgentRestartedAfterEspExit = ctx.AgentRestartedAfterEspExit, + + AadJoinedWithUser = ctx.AadJoinedWithUser, + DesktopArrived = ctx.DesktopArrived, + HasAccountSetupActivity = ctx.HasAccountSetupActivity, + + IsDeviceOnlyDeployment = ctx.IsDeviceOnly, + + EspFinalExitUtc = ctx.EspFinalExitUtc, + }; + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs index 2e773c58..b94c4317 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentStatePersistence.cs @@ -116,8 +116,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment public int? AutopilotMode { get; set; } // True when AAD join status shows a joined device with a real user email + // (placeholder accounts like foouser@/autopilot@ are excluded — see AadJoinInfo) public bool AadJoinedWithUser { get; set; } + // True when a transient AAD provisioning placeholder (foouser@/autopilot@) has been + // observed at any point in the session — survives crash/restart so the classifier + // keeps its positive WG signal after agent recovery. + public bool FooUserDetected { get; set; } + // True when Autopilot profile indicates Hybrid Azure AD Join (CloudAssignedDomainJoinMethod == 1) public bool IsHybridJoin { get; set; } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs index 2ff78a7e..3a8b839e 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.CompletionLogic.cs @@ -112,27 +112,32 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment return; } - // WhiteGlove guard: Only SaveWhiteGloveSuccessResult=succeeded from the ESP registry - // is a reliable WhiteGlove confirmation. EventID 509 is a soft signal only — it fires - // on hybrid-join devices too and must NOT trigger WG completion on its own. - bool whiteGloveStarted = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false; - bool whiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false; + // WhiteGlove guard — canonical decision via WhiteGloveClassifier. + // See plan: all three completion paths (this one, TryEmitEnrollmentComplete, + // and CompletionStateMachine.HandleDeviceSetupProvisioningComplete) share the + // same classifier output. HasSaveWhiteGloveSuccessResult alone is no longer + // sufficient — it also fires on non-WG devices (observed in production). + var wgSignals = SnapshotWhiteGloveSignals(); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + EmitWhiteGloveClassification(wgClassification, "DeviceSetupProvisioningComplete", wgSignals); - if (whiteGloveSuccessResult) + if (wgClassification.ShouldRouteToWhiteGlovePart1) { - _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove confirmed " + - $"(whiteGloveStarted={whiteGloveStarted}, saveWhiteGloveSuccessResult={whiteGloveSuccessResult}) — " + + _logger.Info($"EnrollmentTracker: Device-only deployment with WhiteGlove (classifier: {wgClassification.Reason}) — " + "routing to WhiteGlove completion instead of self_deploying_provisioning_complete"); RecordSignal("whiteglove_guard_activated"); EmitTraceEvent("whiteglove_guard_activated", - "DeviceSetup provisioning complete with SaveWhiteGloveSuccessResult confirmed — routing to WhiteGlove path", + "DeviceSetup provisioning complete with WG classifier Strong — routing to WhiteGlove path", new Dictionary { - { "whiteGloveStartDetected", whiteGloveStarted }, - { "saveWhiteGloveSuccessResult", whiteGloveSuccessResult }, - { "fooUserDetected", _fooUserDetected }, + { "wgScore", wgClassification.Score }, + { "wgConfidence", wgClassification.Confidence.ToString() }, + { "wgFactors", wgClassification.ContributingFactors }, + { "whiteGloveStartDetected", wgSignals.IsWhiteGloveStartDetected }, + { "saveWhiteGloveSuccessResult", wgSignals.HasSaveWhiteGloveSuccessResult }, + { "fooUserDetected", wgSignals.IsFooUserDetected }, { "autopilotMode", _autopilotMode }, { "skipUserStatusPage", _skipUserStatusPage } }); @@ -211,6 +216,11 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment /// private void OnWhiteGloveCompleted(object sender, EventArgs e) { + // Shell-Core Event 62407 "WhiteGlove_Success" has fired — record it so the + // WhiteGloveClassifier can see this hard-positive signal (weight +80) on any + // subsequent classification call in the same session. + lock (_stateLock) { _shellCoreWhiteGloveFired = true; } + var wgSource = sender?.GetType().Name ?? "unknown"; CompleteWhiteGlove(wgSource, decisionAlreadyEmitted: false); } @@ -861,6 +871,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment _skipDeviceStatusPage = loaded.SkipDeviceStatusPage; _autopilotMode = loaded.AutopilotMode; _aadJoinedWithUser = loaded.AadJoinedWithUser; + _fooUserDetected = loaded.FooUserDetected; // Restore hybrid join flag from persisted state; re-detect as fallback _isHybridJoin = loaded.IsHybridJoin || DetectHybridJoinStatic(); _stateData = loaded; @@ -1094,6 +1105,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment _enrollmentCompleteEmitted = true; _stateData.EnrollmentCompleteEmitted = true; signalsSeen = new List(_stateData.SignalsSeen); + + // Stop recurring/watchdog timers under the SAME lock that claims + // the emission flag. Concurrent timer callbacks already past their + // own _enrollmentCompleteEmitted check will no-op further work; + // callbacks not yet past it will read flag=true and return. + // Change(Inf,Inf) is idempotent and deadlock-safe inside the lock; + // Dispose would wait on in-flight callbacks and is risky here. + _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _summaryTimerActive = false; + _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); + _debugStateTimerActive = false; + _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); } else { @@ -1137,38 +1160,37 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment return; } - // GUARD 4: WhiteGlove Part 1 redirect. - // If SaveWhiteGloveSuccessResult=succeeded (hard WG signal from ESP registry) but no real - // user has joined yet, this is WG Part 1 — the device is pre-provisioned and waiting for - // a user. Redirect to whiteglove_complete (Pending) instead of enrollment_complete (Succeeded). - // This catches WG Part 1 with SkipUser=False where ShellCore WhiteGlove_Success doesn't fire. - bool wgSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false; - if (wgSuccessResult && !_aadJoinedWithUser && !desktopArrived) - { - _logger.Info($"EnrollmentTracker: all guards passed but SaveWhiteGloveSuccessResult=succeeded " + - $"with no user joined and no desktop arrived — redirecting to WhiteGlove Part 1 " + - $"(source={source}, aadJoinedWithUser={_aadJoinedWithUser}, desktopArrived={desktopArrived})"); + // GUARD 4: WhiteGlove Part 1 redirect — canonical classifier decision. + // All three WG decision sites go through WhiteGloveClassifier, so this guard + // uses the same rule as OnDeviceSetupProvisioningComplete and the StateMachine. + // ShouldRouteToWhiteGlovePart1==true only on Strong confidence; Weak is + // observable telemetry but falls through to the regular completion path. + var wgSignals = SnapshotWhiteGloveSignals(); + var wgClassification = WhiteGloveClassifier.Classify(wgSignals); + EmitWhiteGloveClassification(wgClassification, "TryEmitEnrollmentComplete", wgSignals); + + if (wgClassification.ShouldRouteToWhiteGlovePart1) + { + _logger.Info($"EnrollmentTracker: all guards passed and WG classifier Strong — " + + $"redirecting to WhiteGlove Part 1 (source={source}, {wgClassification.Reason})"); - // Consolidated decision record: includes the upstream source AND the WG-context - // signals that used to live in a second (now-suppressed) decision record. EmitDecisionProcess("whiteglove_complete", source, "redirected_wg_part1", new List { $"original_source={source}", - "save_whiteglove_succeeded=true", - (_espAndHelloTracker?.IsWhiteGloveStartDetected ?? false) ? "event509_seen" : "event509_not_seen", - "aad_joined_with_user=false", - "desktop_arrived=false", - _fooUserDetected ? "foouser_detected" : "no_foouser", - "redirect: enrollment_complete -> whiteglove_complete" - }); + $"wg_score={wgClassification.Score}", + $"wg_confidence={wgClassification.Confidence}", + }.Concat(wgClassification.ContributingFactors ?? new List()) + .Concat(new[] { "redirect: enrollment_complete -> whiteglove_complete" }) + .ToList(), + classification: wgClassification); // Decision is already recorded above — CompleteWhiteGlove must not emit a second one. - // ShadowProcessTrigger("whiteglove_complete") is called inside CompleteWhiteGlove, so no extra call here. CompleteWhiteGlove(source, decisionAlreadyEmitted: true); return; } - _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete"); + _logger.Info($"EnrollmentTracker: all guards passed for source '{source}' — emitting enrollment_complete " + + $"(wg_classifier: {wgClassification.Reason})"); // Emit decision process before the terminal event EmitDecisionProcess("enrollment_complete", source, "succeeded", new List @@ -1179,15 +1201,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment desktopArrived ? "desktop_arrived" : "desktop_not_arrived", isDeviceOnly ? "device_only" : "user_driven", _isHybridJoin ? "hybrid_join" : "aad_join", - wgSuccessResult ? "wg_registry_succeeded" : "no_wg_registry" - }); + wgSignals.HasSaveWhiteGloveSuccessResult ? "wg_registry_succeeded" : "no_wg_registry", + $"wg_score={wgClassification.Score}", + $"wg_confidence={wgClassification.Confidence}" + }, classification: wgClassification); - // Stop timers - _summaryTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _summaryTimerActive = false; - _debugStateTimer?.Change(Timeout.Infinite, Timeout.Infinite); - _debugStateTimerActive = false; - _deviceOnlyCompletionSafetyTimer?.Change(Timeout.Infinite, Timeout.Infinite); + // Timers were already stopped inside the lock when _enrollmentCompleteEmitted was claimed. var helloOutcome = _espAndHelloTracker?.HelloOutcome ?? "unknown"; @@ -1320,7 +1339,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment /// that led to the outcome — enabling post-hoc analysis without reconstructing from individual events. /// private void EmitDecisionProcess(string terminalEvent, string source, string outcome, - List decisionChain = null) + List decisionChain = null, + WhiteGloveClassification classification = null) { Dictionary data; lock (_stateLock) @@ -1384,6 +1404,16 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment if (_stateData.DeviceSetupProvisioningCompleteUtc.HasValue) ts["deviceSetupProvisioningComplete"] = _stateData.DeviceSetupProvisioningCompleteUtc.Value.ToString("o"); data["signalTimestamps"] = ts; + + // WhiteGlove classifier output — same fields as the whiteglove_classification + // event so aggregate MCP queries can correlate terminal decisions with the + // scoring history across a session. + if (classification != null) + { + data["wgScore"] = classification.Score; + data["wgConfidence"] = classification.Confidence.ToString(); + data["wgFactors"] = classification.ContributingFactors ?? new List(); + } } _emitEvent(new EnrollmentEvent @@ -1402,6 +1432,192 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment _logger.Info($"EnrollmentTracker: decision_process_completion emitted — {terminalEvent} via {source} ({outcome})"); } + // ===================================================================== + // WhiteGlove classification helpers — see WhiteGloveClassifier for the + // scoring rules and thresholds. The three completion call-sites + // (OnDeviceSetupProvisioningComplete, TryEmitEnrollmentComplete, + // CompletionStateMachine.HandleDeviceSetupProvisioningComplete) all + // classify through the same classifier, so decisions converge. + // ===================================================================== + + /// + /// Snapshot current WhiteGlove-relevant state under _stateLock and return a + /// consumable for . + /// + private WhiteGloveSignals SnapshotWhiteGloveSignals() + { + lock (_stateLock) + { + var hasAccountSetup = _espAndHelloTracker?.HasAccountSetupActivity ?? false; + var hasSaveWg = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false; + var wgStart = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false; + + bool agentRestartedAfterEsp = _stateData.EspFinalExitUtc.HasValue + && _agentStartTimeUtc > _stateData.EspFinalExitUtc.Value; + + return new WhiteGloveSignals + { + ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired, + HasSaveWhiteGloveSuccessResult = hasSaveWg, + IsWhiteGloveStartDetected = wgStart, + IsFooUserDetected = _fooUserDetected, + AgentRestartedAfterEspExit = agentRestartedAfterEsp, + + AadJoinedWithUser = _aadJoinedWithUser, + DesktopArrived = _desktopArrived, + HasAccountSetupActivity = hasAccountSetup, + + IsDeviceOnlyDeployment = IsDeviceOnlyDeployment, + + EspFinalExitUtc = _stateData.EspFinalExitUtc, + DeviceSetupProvisioningCompleteUtc = _stateData.DeviceSetupProvisioningCompleteUtc, + }; + } + } + + /// + /// Emits the observability-only whiteglove_classification event with the + /// full scoring breakdown. Enables MCP query_table queries like + /// "distribution of wgConfidence over last 24h" or "drill-down into Weak sessions" + /// without any backend schema change — all fields live inside the existing data bag. + /// + private void EmitWhiteGloveClassification( + WhiteGloveClassification c, + string callSite, + WhiteGloveSignals signals) + { + if (c == null) return; + + var data = new Dictionary + { + { "wgScore", c.Score }, + { "wgConfidence", c.Confidence.ToString() }, + { "callSite", callSite }, + { "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 }, + { "factors", c.ContributingFactors ?? new List() }, + { "signals", new Dictionary + { + { "shellCoreWg", signals.ShellCoreWhiteGloveSuccess }, + { "hasSaveWg", signals.HasSaveWhiteGloveSuccessResult }, + { "event509", signals.IsWhiteGloveStartDetected }, + { "fooUser", signals.IsFooUserDetected }, + { "agentRestartAfterEsp", signals.AgentRestartedAfterEspExit }, + { "aadJoined", signals.AadJoinedWithUser }, + { "desktopArrived", signals.DesktopArrived }, + { "accountSetupActive", signals.HasAccountSetupActivity }, + { "deviceOnly", signals.IsDeviceOnlyDeployment }, + } + }, + }; + + _emitEvent(new EnrollmentEvent + { + SessionId = _sessionId, + TenantId = _tenantId, + EventType = "whiteglove_classification", + Severity = EventSeverity.Info, + Source = "EnrollmentTracker", + Phase = EnrollmentPhase.Unknown, + Message = $"WG classification at {callSite}: {c.Confidence} (score={c.Score})", + ImmediateUpload = false, + Data = data, + }); + } + + /// + /// Called by CollectorCoordinator when the + /// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates + /// _fooUserDetected so the WhiteGloveClassifier sees the positive indicator + /// on the next classification call. Does NOT re-trigger completion — placeholder + /// alone is not a terminal signal. + /// + public void NotifyAadPlaceholderUserDetected(string userEmail) + { + bool alreadyDetected; + lock (_stateLock) + { + if (_enrollmentCompleteEmitted) return; + + alreadyDetected = _fooUserDetected; + if (!alreadyDetected) + { + _fooUserDetected = true; + _stateData.FooUserDetected = true; + } + } + + if (alreadyDetected) + { + _logger.Debug("EnrollmentTracker: NotifyAadPlaceholderUserDetected — already detected, ignoring"); + return; + } + + _statePersistence.Save(_stateData); + RecordSignal("aad_placeholder_user_detected_late"); + _logger.Info("EnrollmentTracker: late placeholder user detected — recording for classifier"); + + var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds; + EmitTraceEvent("aad_placeholder_user_detected_late", + "Transient AAD provisioning placeholder user appeared in JoinInfo registry", + new Dictionary + { + { "secondsSinceAgentStart", secondsSinceStart }, + { "emailPattern", userEmail?.Split('@')[0] ?? "unknown" }, + }); + } + + /// + /// Called by CollectorCoordinator when the + /// detects a real AAD user join (non-placeholder) during the session. Updates + /// _aadJoinedWithUser atomically so subsequent WhiteGloveClassifier calls + /// correctly see the real user (driving IsDeviceOnlyDeployment to false and + /// triggering the classifier's hard-exclude). Re-runs the completion guards. + /// + public void NotifyAadUserJoinedLate(string userEmail) + { + bool alreadyJoined; + bool alreadyEmitted; + lock (_stateLock) + { + alreadyEmitted = _enrollmentCompleteEmitted; + alreadyJoined = _aadJoinedWithUser; + if (!alreadyJoined && !alreadyEmitted) + { + _aadJoinedWithUser = true; + _stateData.AadJoinedWithUser = true; + } + } + + if (alreadyEmitted) + { + _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — enrollment already emitted, ignoring"); + return; + } + if (alreadyJoined) + { + _logger.Debug("EnrollmentTracker: NotifyAadUserJoinedLate — already joined, ignoring"); + return; + } + + _statePersistence.Save(_stateData); + RecordSignal("aad_user_joined_late"); + _logger.Info($"EnrollmentTracker: late AAD user joined ({userEmail}) — re-evaluating completion"); + + var secondsSinceStart = (DateTime.UtcNow - _agentStartTimeUtc).TotalSeconds; + EmitTraceEvent("aad_user_joined_late", + "Real AAD user appeared in JoinInfo registry after session started", + new Dictionary + { + { "secondsSinceAgentStart", secondsSinceStart }, + { "userEmailDomain", userEmail?.Contains("@") == true ? userEmail.Substring(userEmail.IndexOf('@')) : "unknown" }, + }); + + // Re-evaluate guards with fresh AAD-joined state. The classifier will now hard-exclude + // a WhiteGlove classification for this session; the standard enrollment_complete path + // can now fire once Hello/desktop resolve. + TryEmitEnrollmentComplete("aad_user_joined_late"); + } + /// /// Called by MonitoringService when Desktop Arrival is detected (explorer.exe under a real user). /// Corrects phase if needed, starts Hello wait timer in no-ESP scenarios, and attempts completion. diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs index 82fb122b..b21efe82 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/EnrollmentTracker.cs @@ -104,9 +104,17 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment // True when AAD join status shows a joined device with a real user email. // Detected via CollectAadJoinStatus() in DeviceInfoCollector. private bool _aadJoinedWithUser; - // Soft indicator: AAD userEmail starts with "foouser@" (pre-provisioning synthetic account). + // Soft indicator: AAD userEmail matches a transient provisioning placeholder pattern + // (foouser@, autopilot@). These accounts appear during Autopilot pre-provisioning + // (WhiteGlove) and MUST NOT be treated as a real AAD-joined user. private bool _fooUserDetected; + // Set to true the moment the Shell-Core Event-62407 "WhiteGlove_Success" handler fires. + // Distinct from (which is the + // fire-once guard inside CompleteWhiteGlove and covers any completion path). Feeds + // the WhiteGloveClassifier as the strongest positive signal. + private bool _shellCoreWhiteGloveFired; + // Signal-correlated WhiteGlove detection (Ebene 2.6): // Feeds the bestehenden OnWhiteGloveCompleted() callback when the Shell-Core detector // does not match but the session shows the WhiteGlove sealing pattern (all Device-apps done, @@ -224,6 +232,8 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment HasAccountSetupActivity = _espAndHelloTracker?.HasAccountSetupActivity ?? false, WhiteGloveStartDetected = _espAndHelloTracker?.IsWhiteGloveStartDetected ?? false, HasSaveWhiteGloveSuccessResult = _espAndHelloTracker?.HasSaveWhiteGloveSuccessResult ?? false, + ShellCoreWhiteGloveSuccess = _shellCoreWhiteGloveFired, + IsFooUserDetected = _fooUserDetected, AgentStartTimeUtc = _agentStartTimeUtc, EspFinalExitUtc = _stateData.EspFinalExitUtc, ImePatternSeenUtc = _stateData.ImePatternSeenUtc @@ -628,6 +638,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment _stateData.SkipDeviceStatusPage = _skipDeviceStatusPage; _stateData.AutopilotMode = _autopilotMode; _stateData.AadJoinedWithUser = _aadJoinedWithUser; + _stateData.FooUserDetected = _fooUserDetected; _stateData.IsHybridJoin = _isHybridJoin; _stateDirty = true; } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs new file mode 100644 index 00000000..d9ddfee1 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinInfo.cs @@ -0,0 +1,84 @@ +using System; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Reads AAD join information from the Windows registry and classifies the + /// signed-in user e-mail into "real user" vs. "provisioning placeholder". + /// + /// Registry path: HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo\<thumbprint>\UserEmail + /// + /// Placeholder users () appear transiently during + /// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join. + /// + internal static class AadJoinInfo + { + internal const string JoinInfoRegistryPath = + @"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo"; + + /// + /// Reads the first JoinInfo sub-key and returns the UserEmail + thumbprint. + /// Returns true when ANY sub-key was found (even without UserEmail); the caller + /// inspects the out-parameters to decide semantics. + /// + /// Raw UserEmail value (may be null/empty). + /// The certificate thumbprint sub-key name (may be null). + /// + /// True when matches a known provisioning-placeholder + /// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join. + /// + internal static bool TryReadAadJoinedUser( + out string userEmail, + out string thumbprint, + out bool isPlaceholderUser) + { + userEmail = null; + thumbprint = null; + isPlaceholderUser = false; + + try + { + using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(JoinInfoRegistryPath)) + { + if (joinInfoKey == null) + return false; + + var subKeyNames = joinInfoKey.GetSubKeyNames(); + if (subKeyNames == null || subKeyNames.Length == 0) + return false; + + thumbprint = subKeyNames[0]; + using (var subKey = joinInfoKey.OpenSubKey(thumbprint)) + { + if (subKey == null) + return true; + + userEmail = subKey.GetValue("UserEmail")?.ToString(); + isPlaceholderUser = IsPlaceholderUserEmail(userEmail); + } + } + + return true; + } + catch + { + return false; + } + } + + /// + /// Returns true when the given email matches a known transient provisioning-account + /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot device + /// preparation and must not be treated as a real signed-in user. + /// + internal static bool IsPlaceholderUserEmail(string email) + { + if (string.IsNullOrWhiteSpace(email)) + return false; + + return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase) + || email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase); + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs new file mode 100644 index 00000000..73f3dcb9 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Enrollment/SystemSignals/AadJoinWatcher.cs @@ -0,0 +1,251 @@ +using System; +using System.Threading; +using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Interop; +using AutopilotMonitor.Agent.Core.Monitoring.Runtime; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals +{ + public sealed class AadPlaceholderUserDetectedEventArgs : EventArgs + { + public string UserEmail { get; } + public string Thumbprint { get; } + public AadPlaceholderUserDetectedEventArgs(string userEmail, string thumbprint) + { + UserEmail = userEmail; + Thumbprint = thumbprint; + } + } + + public sealed class AadUserJoinedEventArgs : EventArgs + { + public string UserEmail { get; } + public string Thumbprint { get; } + public AadUserJoinedEventArgs(string userEmail, string thumbprint) + { + UserEmail = userEmail; + Thumbprint = thumbprint; + } + } + + /// + /// Watches HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo for a + /// late AAD user join. Uses (RegNotifyChangeKeyValue) for + /// instant, low-overhead change detection — no polling. + /// + /// Two distinct events are raised: + /// + /// — first time a transient + /// provisioning account (foouser@*, autopilot@*) appears. Fire-once. + /// The watcher keeps running afterwards, as the placeholder is expected to be + /// replaced by a real user. + /// — first time a REAL user e-mail + /// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards. + /// + /// + /// Retry semantics: the JoinInfo root key may not exist yet when the agent + /// starts — it is created during AAD provisioning. A retry timer polls for its + /// appearance every 2 seconds until it exists, then attaches the registry watcher. + /// + internal sealed class AadJoinWatcher : IDisposable + { + internal const int RetryIntervalSeconds = 2; + internal const int DebounceMilliseconds = 500; + + private readonly AgentLogger _logger; + private readonly object _stateLock = new object(); + + private RegistryWatcher _watcher; + private Timer _retryTimer; + private Timer _debounceTimer; + + private bool _placeholderFired; + private bool _realUserFired; + private bool _disposed; + + public event EventHandler PlaceholderUserDetected; + public event EventHandler AadUserJoined; + + public AadJoinWatcher(AgentLogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Start() + { + if (_disposed) throw new ObjectDisposedException(nameof(AadJoinWatcher)); + + // Initial synchronous read. If a real AAD user is already present (typical after + // DeviceInfoCollector ran at agent startup), fire AadUserJoined once and do NOT + // attach the registry watcher — there is nothing left to watch for. + CheckJoinInfo(); + bool terminalFromStartup; + lock (_stateLock) { terminalFromStartup = _realUserFired; } + if (terminalFromStartup) + { + _logger.Info("AadJoinWatcher: real AAD user already present at startup — skipping RegistryWatcher attach"); + return; + } + + if (!TryStartWatcher()) + { + _logger.Info("AadJoinWatcher: JoinInfo key not yet present — retrying every 2s"); + _retryTimer = new Timer( + _ => + { + if (TryStartWatcher()) + { + _retryTimer?.Dispose(); + _retryTimer = null; + } + }, + null, + TimeSpan.FromSeconds(RetryIntervalSeconds), + TimeSpan.FromSeconds(RetryIntervalSeconds)); + } + } + + public void Stop(string reason = "watcher_stopped") + { + try + { + lock (_stateLock) + { + _retryTimer?.Dispose(); + _retryTimer = null; + + _debounceTimer?.Dispose(); + _debounceTimer = null; + + if (_watcher != null) + { + _watcher.Dispose(); + _watcher = null; + _logger.Info($"AadJoinWatcher: stopped ({reason})"); + } + } + } + catch (Exception ex) + { + _logger.Error("AadJoinWatcher: error during Stop", ex); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + Stop("disposed"); + } + + private bool TryStartWatcher() + { + try + { + using (var key = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath)) + { + if (key == null) return false; + } + + lock (_stateLock) + { + if (_watcher != null) return true; // Already running + + _debounceTimer = new Timer( + _ => CheckJoinInfo(), + null, + Timeout.Infinite, + Timeout.Infinite); + + _watcher = new RegistryWatcher( + RegistryHive.LocalMachine, + AadJoinInfo.JoinInfoRegistryPath, + watchSubtree: true, + filter: RegistryNativeMethods.RegChangeNotifyFilter.LastSet + | RegistryNativeMethods.RegChangeNotifyFilter.Name, + trace: msg => _logger.Trace($"AadJoinWatcher(reg): {msg}")); + + _watcher.Changed += (s, e) => + { + _logger.Trace("AadJoinWatcher: registry changed — debouncing"); + _debounceTimer?.Change(DebounceMilliseconds, Timeout.Infinite); + }; + _watcher.Error += (s, ex) => _logger.Warning($"AadJoinWatcher: watcher error: {ex.Message}"); + + _watcher.Start(); + _logger.Info("AadJoinWatcher: RegistryWatcher attached to JoinInfo"); + } + + return true; + } + catch (Exception ex) + { + _logger.Warning($"AadJoinWatcher: failed to start watcher: {ex.Message}"); + return false; + } + } + + private void CheckJoinInfo() + { + try + { + // Already reached terminal state? Stop any further processing. + lock (_stateLock) + { + if (_realUserFired) return; + } + + if (!AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)) + { + // Key gone or unreadable — keep watching, may come back + return; + } + + if (string.IsNullOrWhiteSpace(userEmail)) + { + // Sub-key exists but no UserEmail value yet — nothing to act on + return; + } + + bool firePlaceholder = false; + bool fireRealUser = false; + lock (_stateLock) + { + if (isPlaceholderUser && !_placeholderFired) + { + _placeholderFired = true; + firePlaceholder = true; + } + else if (!isPlaceholderUser && !_realUserFired) + { + _realUserFired = true; + fireRealUser = true; + } + } + + if (firePlaceholder) + { + _logger.Info($"AadJoinWatcher: placeholder user detected ({userEmail}) — pre-provisioning indicator"); + try { PlaceholderUserDetected?.Invoke(this, new AadPlaceholderUserDetectedEventArgs(userEmail, thumbprint)); } + catch (Exception ex) { _logger.Error("AadJoinWatcher: PlaceholderUserDetected handler threw", ex); } + } + + if (fireRealUser) + { + _logger.Info($"AadJoinWatcher: real AAD user joined ({userEmail}) — firing AadUserJoined and stopping watcher"); + try { AadUserJoined?.Invoke(this, new AadUserJoinedEventArgs(userEmail, thumbprint)); } + catch (Exception ex) { _logger.Error("AadJoinWatcher: AadUserJoined handler threw", ex); } + + // Terminal — stop the watcher. Run on a thread-pool thread to avoid + // callback-in-callback lock edge cases with the RegistryWatcher worker. + ThreadPool.QueueUserWorkItem(_ => Stop("real_user_joined")); + } + } + catch (Exception ex) + { + _logger.Warning($"AadJoinWatcher: CheckJoinInfo threw: {ex.Message}"); + } + } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs index ab0148b8..a08e7b37 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Runtime/CollectorCoordinator.cs @@ -55,6 +55,7 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime // Desktop arrival / IME watcher / Network change private DesktopArrivalDetector _desktopArrivalDetector; + private AadJoinWatcher _aadJoinWatcher; private ImeProcessWatcher _imeProcessWatcher; private NetworkChangeDetector _networkChangeDetector; @@ -356,6 +357,12 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime _desktopArrivalDetector.Start(); _logger.Info("DesktopArrivalDetector started — monitoring for real user desktop"); + _aadJoinWatcher = new AadJoinWatcher(_logger); + _aadJoinWatcher.PlaceholderUserDetected += OnAadPlaceholderUserDetected; + _aadJoinWatcher.AadUserJoined += OnAadUserJoined; + _aadJoinWatcher.Start(); + _logger.Info("AadJoinWatcher started — monitoring JoinInfo registry for late AAD user"); + _imeProcessWatcher = new ImeProcessWatcher( _configuration.SessionId, _configuration.TenantId, @@ -410,6 +417,14 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime _desktopArrivalDetector = null; } + if (_aadJoinWatcher != null) + { + _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected; + _aadJoinWatcher.AadUserJoined -= OnAadUserJoined; + _aadJoinWatcher.Dispose(); + _aadJoinWatcher = null; + } + _imeProcessWatcher?.Dispose(); _imeProcessWatcher = null; @@ -570,7 +585,37 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Runtime _desktopArrivalDetector.DesktopArrived -= OnDesktopArrived; _desktopArrivalDetector.Dispose(); } + if (_aadJoinWatcher != null) + { + _aadJoinWatcher.PlaceholderUserDetected -= OnAadPlaceholderUserDetected; + _aadJoinWatcher.AadUserJoined -= OnAadUserJoined; + _aadJoinWatcher.Dispose(); + } _imeProcessWatcher?.Dispose(); } + + private void OnAadPlaceholderUserDetected(object sender, AadPlaceholderUserDetectedEventArgs e) + { + try + { + _enrollmentTracker?.NotifyAadPlaceholderUserDetected(e.UserEmail); + } + catch (Exception ex) + { + _logger.Error("CollectorCoordinator: OnAadPlaceholderUserDetected failed", ex); + } + } + + private void OnAadUserJoined(object sender, AadUserJoinedEventArgs e) + { + try + { + _enrollmentTracker?.NotifyAadUserJoinedLate(e.UserEmail); + } + catch (Exception ex) + { + _logger.Error("CollectorCoordinator: OnAadUserJoined failed", ex); + } + } } } diff --git a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs index 03270fcb..59aba960 100644 --- a/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ b/src/Agent/AutopilotMonitor.Agent.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -7,6 +7,7 @@ using System.Net.NetworkInformation; using System.Text.Json; using System.Threading.Tasks; using AutopilotMonitor.Agent.Core.Logging; +using AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals; using AutopilotMonitor.Agent.Core.Security; using AutopilotMonitor.Shared.Models; using Microsoft.Win32; @@ -20,16 +21,18 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo public partial class DeviceInfoCollector { /// - /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail. + /// True when AAD join status shows "Azure AD Joined" with a non-empty userEmail + /// that is NOT a provisioning placeholder (see ). /// Used by EnrollmentTracker to distinguish user-driven from device-only deployments /// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too). /// public bool HasAadJoinedUser { get; private set; } /// - /// True when the AAD join userEmail starts with "foouser@" — a synthetic account used - /// by Windows during Autopilot pre-provisioning (WhiteGlove) for AAD discovery. - /// Soft indicator for pre-provisioning; does not affect completion logic. + /// True when the AAD join userEmail matches a known transient provisioning-account + /// pattern (foouser@*, autopilot@*). These accounts appear during Autopilot + /// pre-provisioning (WhiteGlove) and are NOT a real AAD join — they are a soft + /// positive WhiteGlove indicator and feed into . /// public bool IsFooUserDetected { get; private set; } @@ -447,40 +450,36 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo { var data = new Dictionary(); - using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo")) + // Read AAD join info via shared helper. Placeholder users (foouser@, autopilot@) + // are transient provisioning accounts and must NOT be treated as a real AAD join; + // HasAadJoinedUser is only set for real user emails. + if (AadJoinInfo.TryReadAadJoinedUser(out var userEmail, out var thumbprint, out var isPlaceholderUser)) { - if (joinInfoKey != null) + // Still need TenantId — open the subkey directly when we have a thumbprint. + if (!string.IsNullOrEmpty(thumbprint)) { - var subKeyNames = joinInfoKey.GetSubKeyNames(); - if (subKeyNames.Length > 0) + using (var joinInfoKey = Registry.LocalMachine.OpenSubKey(AadJoinInfo.JoinInfoRegistryPath)) + using (var subKey = joinInfoKey?.OpenSubKey(thumbprint)) { - using (var subKey = joinInfoKey.OpenSubKey(subKeyNames[0])) - { - if (subKey != null) - { - var userEmail = subKey.GetValue("UserEmail")?.ToString(); - data["tenantId"] = subKey.GetValue("TenantId")?.ToString(); - data["userEmail"] = userEmail; - data["joinType"] = "Azure AD Joined"; - data["thumbprint"] = subKeyNames[0]; // Certificate thumbprint - - if (!string.IsNullOrWhiteSpace(userEmail)) - { - HasAadJoinedUser = true; - - if (userEmail.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)) - { - IsFooUserDetected = true; - data["isFooUser"] = true; - _logger.Info($"AAD join: foouser@ detected ({userEmail}) — pre-provisioning indicator"); - } - } - } - } + data["tenantId"] = subKey?.GetValue("TenantId")?.ToString(); } - else + + data["userEmail"] = userEmail; + data["joinType"] = "Azure AD Joined"; + data["thumbprint"] = thumbprint; + + if (!string.IsNullOrWhiteSpace(userEmail)) { - data["joinType"] = "Not Joined"; + if (isPlaceholderUser) + { + IsFooUserDetected = true; + data["isFooUser"] = true; + _logger.Info($"AAD join: placeholder user detected ({userEmail}) — pre-provisioning indicator, NOT a real AAD join"); + } + else + { + HasAadJoinedUser = true; + } } } else @@ -488,6 +487,10 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo data["joinType"] = "Not Joined"; } } + else + { + data["joinType"] = "Not Joined"; + } object joinTypeValue; var joinType = data.TryGetValue("joinType", out joinTypeValue) ? joinTypeValue?.ToString() ?? "Unknown" : "Unknown"; diff --git a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt index 4ac943d1..0ff3e5b1 100644 --- a/src/Agent/AutopilotMonitor.Agent/buildcounter.txt +++ b/src/Agent/AutopilotMonitor.Agent/buildcounter.txt @@ -1 +1 @@ -1032 +1033 diff --git a/tasks/todo.md b/tasks/todo.md index bd4ecab5..97fd47d5 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,3 +1,14 @@ # Tasks -_Keine offenen Tasks._ +## Post-Deploy: WhiteGlove Scoring (Deploy-Datum: _YYYY-MM-DD eintragen bei Rollout_) + +Plan: `C:\Users\OliverKieselbach\.claude\plans\1-whiteglove-klassifizierung-inkonsiste-piped-chipmunk.md` +Pflicht-Checks nach Deploy. Siehe [feedback_post_deploy_in_todo.md](C:/Users/OliverKieselbach/.claude/projects/c--Code-GitHubRepos-Autopilot-Monitor/memory/feedback_post_deploy_in_todo.md). + +- [ ] **+24h** — Scoring-Verteilung Baseline: `query_table(table="Events", filter="EventType eq 'whiteglove_classification' and Timestamp ge <24h-ago>", select="data, SessionId, Timestamp")` → nach `data.wgConfidence` gruppieren (Strong/Weak/None); Ergebnis protokollieren +- [ ] **+24h** — Late-AAD-Rate: `query_table(... filter="EventType eq 'aad_user_joined_late'")` + gleiche Query für `'aad_placeholder_user_detected_late'` → niedrig aber nicht null erwartet +- [ ] **+24h** — Eigene Test-VM(s) prüfen: müssen `wgConfidence=None` liefern, **nie** `Weak` oder `Strong` (konkrete DeviceId/SessionId hier eintragen) +- [ ] **+72h** — Weak-Band drill-down: `query_table(... filter="EventType eq 'whiteglove_classification'")` → Client-Filter `wgConfidence=="Weak"` → `get_session_events(sessionId)` für ≥5 Sessions; sind das echte WG-false-negatives oder legitime Grenzfälle? +- [ ] **+7d** — Gewichts-Review: brauchen `ShellCoreWhiteGloveSuccess (+80)` oder `HasSaveWhiteGloveSuccessResult (+10)` Anpassung? Falls ja Follow-up-Ticket +- [ ] **+7d** — `shadow_discrepancy`-Rate vs. Baseline vor Deploy (siehe [project_shadow_sm_cutover.md](C:/Users/OliverKieselbach/.claude/projects/c--Code-GitHubRepos-Autopilot-Monitor/memory/project_shadow_sm_cutover.md)): sollte fallen, nicht steigen +- [ ] Ergebnisse inline dokumentieren (z.B. `✓ 2026-04-20: 142 Strong / 18 Weak / 3400 None`)