mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 04:32:32 +00:00
feat(agent): WhiteGlove Detection Classifier and late-AADJ support
This commit is contained in:
@@ -117,3 +117,4 @@ scripts/Deployment/build_and_upload_release_agent_build.ps1
|
||||
.vscode/
|
||||
TODO.txt
|
||||
tools/orphan-analysis.py
|
||||
tasks/todo.md
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Integration-style tests for <see cref="AadJoinWatcher"/>.
|
||||
/// 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 <see cref="AadJoinInfo.IsPlaceholderUserEmail"/>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class AadJoinWatcherTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_Throws()
|
||||
{
|
||||
Assert.Throws<ArgumentNullException>(() => 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<ObjectDisposedException>(() => 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
+80
-5
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
-8
@@ -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
|
||||
// <see cref="WhiteGloveClassifier"/>.
|
||||
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<string> { "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;
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Confidence level for a WhiteGlove Part 1 classification.
|
||||
/// </summary>
|
||||
internal enum WhiteGloveConfidence
|
||||
{
|
||||
/// <summary>Score below <see cref="WhiteGloveClassifier.LowThreshold"/> — definitely not WhiteGlove.</summary>
|
||||
None,
|
||||
|
||||
/// <summary>Score between low and high thresholds — ambiguous. Routed as NOT WhiteGlove (asymmetric-conservative).</summary>
|
||||
Weak,
|
||||
|
||||
/// <summary>Score at or above <see cref="WhiteGloveClassifier.HighThreshold"/> — classify as WhiteGlove Part 1.</summary>
|
||||
Strong,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a <see cref="WhiteGloveClassifier.Classify"/> call. Contains the
|
||||
/// final routing decision, the numeric score and the contributing factors for
|
||||
/// observability / tuning.
|
||||
/// </summary>
|
||||
internal sealed class WhiteGloveClassification
|
||||
{
|
||||
public WhiteGloveConfidence Confidence { get; set; }
|
||||
public int Score { get; set; }
|
||||
public List<string> ContributingFactors { get; set; } = new List<string>();
|
||||
public string Reason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Single Boolean the caller should read to decide whether to route to
|
||||
/// <c>whiteglove_complete</c> (Pending) instead of the normal completion path.
|
||||
/// Only true when <see cref="Confidence"/> is <see cref="WhiteGloveConfidence.Strong"/>.
|
||||
/// </summary>
|
||||
public bool ShouldRouteToWhiteGlovePart1 { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scoring-based WhiteGlove Part 1 classifier — modelled on the backend
|
||||
/// <c>AnalyzeRule.BaseConfidence + ConfidenceFactors + ConfidenceThreshold</c> pattern.
|
||||
///
|
||||
/// Asymmetric-conservative decision rule: only a <see cref="WhiteGloveConfidence.Strong"/>
|
||||
/// score routes to WhiteGlove. A <see cref="WhiteGloveConfidence.Weak"/> 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
|
||||
/// <c>enrollment_complete</c> early and the later user sign-in triggers a new session —
|
||||
/// the user-visible cost of false-negative is much lower than false-positive.
|
||||
/// </summary>
|
||||
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<string>();
|
||||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion
|
||||
{
|
||||
/// <summary>
|
||||
/// Snapshot of all signals relevant for WhiteGlove (Part 1) classification.
|
||||
/// Populated under _stateLock in the caller and consumed outside the lock by
|
||||
/// <see cref="WhiteGloveClassifier.Classify"/> — 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 <see cref="FromContext"/> to keep both
|
||||
/// decision paths in sync.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>
|
||||
/// Builds a signal snapshot from a <see cref="CompletionContext"/>. Used by the
|
||||
/// CompletionStateMachine so that state-machine decisions use the exact same
|
||||
/// inputs as the EnrollmentTracker's direct decisions.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
@@ -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; }
|
||||
|
||||
|
||||
+259
-43
@@ -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<string, object>
|
||||
{
|
||||
{ "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
|
||||
/// </summary>
|
||||
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<string>(_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<string>
|
||||
{
|
||||
$"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<string>())
|
||||
.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<string>
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
private void EmitDecisionProcess(string terminalEvent, string source, string outcome,
|
||||
List<string> decisionChain = null)
|
||||
List<string> decisionChain = null,
|
||||
WhiteGloveClassification classification = null)
|
||||
{
|
||||
Dictionary<string, object> 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<string>();
|
||||
}
|
||||
}
|
||||
|
||||
_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.
|
||||
// =====================================================================
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot current WhiteGlove-relevant state under _stateLock and return a
|
||||
/// consumable <see cref="WhiteGloveSignals"/> for <see cref="WhiteGloveClassifier.Classify"/>.
|
||||
/// </summary>
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the observability-only <c>whiteglove_classification</c> event with the
|
||||
/// full scoring breakdown. Enables MCP <c>query_table</c> 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.
|
||||
/// </summary>
|
||||
private void EmitWhiteGloveClassification(
|
||||
WhiteGloveClassification c,
|
||||
string callSite,
|
||||
WhiteGloveSignals signals)
|
||||
{
|
||||
if (c == null) return;
|
||||
|
||||
var data = new Dictionary<string, object>
|
||||
{
|
||||
{ "wgScore", c.Score },
|
||||
{ "wgConfidence", c.Confidence.ToString() },
|
||||
{ "callSite", callSite },
|
||||
{ "shouldRouteToWg", c.ShouldRouteToWhiteGlovePart1 },
|
||||
{ "factors", c.ContributingFactors ?? new List<string>() },
|
||||
{ "signals", new Dictionary<string, object>
|
||||
{
|
||||
{ "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,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by CollectorCoordinator when the <see cref="SystemSignals.AadJoinWatcher"/>
|
||||
/// detects a transient provisioning placeholder user (foouser@/autopilot@). Updates
|
||||
/// <c>_fooUserDetected</c> so the WhiteGloveClassifier sees the positive indicator
|
||||
/// on the next classification call. Does NOT re-trigger completion — placeholder
|
||||
/// alone is not a terminal signal.
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "secondsSinceAgentStart", secondsSinceStart },
|
||||
{ "emailPattern", userEmail?.Split('@')[0] ?? "unknown" },
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called by CollectorCoordinator when the <see cref="SystemSignals.AadJoinWatcher"/>
|
||||
/// detects a real AAD user join (non-placeholder) during the session. Updates
|
||||
/// <c>_aadJoinedWithUser</c> atomically so subsequent WhiteGloveClassifier calls
|
||||
/// correctly see the real user (driving <c>IsDeviceOnlyDeployment</c> to false and
|
||||
/// triggering the classifier's hard-exclude). Re-runs the completion guards.
|
||||
/// </summary>
|
||||
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<string, object>
|
||||
{
|
||||
{ "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");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
|
||||
@@ -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 <see cref="_signalCorrelatedWhiteGloveTriggered"/> (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;
|
||||
}
|
||||
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using Microsoft.Win32;
|
||||
|
||||
namespace AutopilotMonitor.Agent.Core.Monitoring.Enrollment.SystemSignals
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 (<see cref="IsPlaceholderUserEmail"/>) appear transiently during
|
||||
/// Autopilot pre-provisioning (WhiteGlove) and must NOT be counted as a real AAD join.
|
||||
/// </summary>
|
||||
internal static class AadJoinInfo
|
||||
{
|
||||
internal const string JoinInfoRegistryPath =
|
||||
@"SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo";
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="userEmail">Raw UserEmail value (may be null/empty).</param>
|
||||
/// <param name="thumbprint">The certificate thumbprint sub-key name (may be null).</param>
|
||||
/// <param name="isPlaceholderUser">
|
||||
/// True when <paramref name="userEmail"/> matches a known provisioning-placeholder
|
||||
/// pattern (foouser@*, autopilot@*). Placeholders are NOT a real AAD join.
|
||||
/// </param>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal static bool IsPlaceholderUserEmail(string email)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(email))
|
||||
return false;
|
||||
|
||||
return email.StartsWith("foouser@", StringComparison.OrdinalIgnoreCase)
|
||||
|| email.StartsWith("autopilot@", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
}
|
||||
+251
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches <c>HKLM\SYSTEM\CurrentControlSet\Control\CloudDomainJoin\JoinInfo</c> for a
|
||||
/// late AAD user join. Uses <see cref="RegistryWatcher"/> (RegNotifyChangeKeyValue) for
|
||||
/// instant, low-overhead change detection — no polling.
|
||||
///
|
||||
/// Two distinct events are raised:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><see cref="PlaceholderUserDetected"/> — first time a transient
|
||||
/// provisioning account (<c>foouser@*</c>, <c>autopilot@*</c>) appears. Fire-once.
|
||||
/// The watcher keeps running afterwards, as the placeholder is expected to be
|
||||
/// replaced by a real user.</description></item>
|
||||
/// <item><description><see cref="AadUserJoined"/> — first time a REAL user e-mail
|
||||
/// (non-placeholder) appears. Fire-once. The watcher auto-stops afterwards.</description></item>
|
||||
/// </list>
|
||||
///
|
||||
/// Retry semantics: the <c>JoinInfo</c> 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.
|
||||
/// </summary>
|
||||
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<AadPlaceholderUserDetectedEventArgs> PlaceholderUserDetected;
|
||||
public event EventHandler<AadUserJoinedEventArgs> 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+36
-33
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// 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 <see cref="IsFooUserDetected"/>).
|
||||
/// Used by EnrollmentTracker to distinguish user-driven from device-only deployments
|
||||
/// when SkipUserStatusPage=true (which admins commonly set for user-driven enrollments too).
|
||||
/// </summary>
|
||||
public bool HasAadJoinedUser { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="AutopilotMonitor.Agent.Core.Monitoring.Enrollment.Completion.WhiteGloveClassifier"/>.
|
||||
/// </summary>
|
||||
public bool IsFooUserDetected { get; private set; }
|
||||
|
||||
@@ -447,40 +450,36 @@ namespace AutopilotMonitor.Agent.Core.Monitoring.Telemetry.DeviceInfo
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
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";
|
||||
|
||||
@@ -1 +1 @@
|
||||
1032
|
||||
1033
|
||||
|
||||
+12
-1
@@ -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`)
|
||||
|
||||
Reference in New Issue
Block a user