diff --git a/docs/agent/registry-app-state-pillar.md b/docs/agent/registry-app-state-pillar.md new file mode 100644 index 00000000..94a69ed2 --- /dev/null +++ b/docs/agent/registry-app-state-pillar.md @@ -0,0 +1,89 @@ +--- +type: concept +title: Registry App-State Second Pillar +description: Why the agent reads IME's authoritative per-app state straight from the registry (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), how the snapshot-and-diff observer works, and how registry/log reconciliation doubles as the built-in IME log-pattern drift alarm. +resource: agent +tags: [ime, registry, win32apps, observability, reconciliation, pattern-drift, wdp] +timestamp: 2026-08-18 +--- + +# Schema + +## Why a second pillar + +The 2026-08-17 audit of the decompiled IME (1.97.107 / 1.104.102) proved that IME log +wording is an unstable contract: Microsoft removed the old V1/V2 processing pipeline +(killing 9 patterns silently), reworded the post-remediation line, changed the +report-delta quoting between 1.97 and 1.104, and deleted `[DO TEL]` entirely. The +registry, by contrast, is IME's *internal product contract* — the ESP page and Intune +reporting consume it — and cannot drift casually. + +Architecture decision (Oliver, 2026-08-17): **hybrid**. Logs stay the narrative +(downloads, retries, exit codes, scripts — the timeline). The registry is the stable +truth for terminal state. Divergence between the two IS the alarm that log patterns +drifted — turning silent data loss into a Warning event. + +## Observed surfaces + +All under `HKLM\SOFTWARE\Microsoft\IntuneManagementExtension` (64-bit view), verified +against decompiled IME source: + +| Surface | Content | +|---|---| +| `Win32Apps\\_` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. | +| `EspTrackingWin32Apps\\_` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. | +| `SideCarPolicies\StatusServiceReports\\` | The `AppInstallStatusReport` the ESP page renders (`Status`: 1000=Installed, 1001=Installing, 3000=Failed, 2000er=NotApplicable). | + +## Mechanism + +`ImeRegistryAppStateObserver` + `ImeRegistryAppStateHost` (always-on, no config gate — +precedent `EspPolicyProviderStallHost`): + +- **One recursive `RegistryWatcher`** on the IME root covers all three surfaces; + changes are debounced (2 s trailing edge) into a tick. A 60-s periodic tick is both + the reconciliation-settle evaluator and the poll-only fallback when the watcher + fails to arm (`collector_degraded`, once). +- **Snapshot-and-diff**: `RegNotifyChangeKeyValue` gives coalesced key-scope edges + only, so every tick re-reads the surfaces and diffs against the previous snapshot. +- **Silent baseline**: the state present at agent start is captured without events. + Win32Apps keys survive re-enrollments — replaying them would be the registry twin + of the historic-IME-replay bug. Only apps that *change during the session* are + reported or judged. +- **`registry_app_state`** (Info): emitted per real field change + (enforcementState/errorCode/exitCode/statusServiceStatus/espTracked/espPhase), + capped at 200/session (cap announced once as Warning). +- **`app_state_reconciliation`** (Warning, once per app, immediate upload): after a + changed entry has been terminal (EnforcementState 1000er=success / 5000er=error, + or StatusService 1000er/3000) for ≥ 90 s, its outcome is compared against the + log-derived `AppPackageState` from `ImeLogHost.AllKnownPackageStates`. Divergence + reasons: `registry_error_log_installed`, `registry_success_log_error`, + `app_unknown_to_log_tracking` (only when the tracker is actively tracking apps — + an idle tracker is not judgeable). +- **Pure observability**: nothing feeds the DecisionEngine (same rule as + oobe-state observability). + +## WDP reuse (Paket 3) + +The same host archetype (RegistryWatcher subtree + debounce + snapshot-diff) is the +intended vehicle for WDP live progress: watch +`HKLM\SOFTWARE\Microsoft\Provisioning\AutopilotSettings\DevicePreparation\BootstrapperAgent` +(`ProgressUpdates` rows, `ResumeContext.ProvisioningResult` terminal marker) with the +RealmJoinWatcher appearance-watcher pattern for not-yet-existing keys, plus an +Event-142 channel watcher (`Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider`, +channel handling precedent in `DiagnosticsPackageService`). Win32Apps keys are written +on WDP devices too — the pillar works on both rails unchanged. + +# Examples + +Timeline evidence of a drifted pattern set: apps install fine (registry says +EnforcementState 1000) but the timeline shows no `app_install_completed` — each app +raises one `app_state_reconciliation` with `reason=app_unknown_to_log_tracking`. +Operator response: run `/ime-pattern-validate` against the session, fix patterns, +backend-deploy (no agent release needed for pattern fixes). + +# Citations + +- `src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs` +- `src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs` +- `tasks/ime-v1-code-audit-2026-08-17.md` (decompiled-source audit; registry surface catalog) +- Decompile archive: `okieselbach/ime-decompiles` (private), `C:\Code\GitHubRepos\ime-decompiles\\decompiled` diff --git a/docs/index.md b/docs/index.md index 37362e8a..781c84b2 100644 --- a/docs/index.md +++ b/docs/index.md @@ -25,6 +25,7 @@ https://docs.autopilotmonitor.com (separate repository). * [MDM Reboot Coalescing](agent/mdm-reboot-coalescing.md) - Attributing the mid-ESP coalesced reboot ("second sign-in") to the device-assigned policy URIs that forced it: aggregated DM-Enterprise 2800 observations (neutral, flush-time watermark) + the ANALYZE-ESP-005 advisory gated on an actually-observed reboot. * [Secure Boot CA-2023 Detection](agent/secureboot-ca2023-detection.md) - Why ANALYZE-SEC-001 v3 reads the UEFI db/KEK variables directly instead of trusting the SecureBoot\Servicing registry, and how the one-sided uefiCa2023FirmwareConfirmed marker plus a not_exists precondition give absence-tolerant suppression across old and new agents. * [Continue-Anyway Observation Mode](agent/continue-anyway-observation.md) - EnableEspContinueAnywayObservation (ConfigVersion 37, operator-set): Device-phase ESP terminal failures on Continue-Anyway profiles get a bounded 60-min observation instead of an immediate hard fail; the DAD-validated real-user desktop + Hello gate detect the true end, the session completes Succeeded + EspSoftFailure (amber "with issues" badge), expiry un-defangs to the original esp_terminal_failure. +* [Registry App-State Second Pillar](agent/registry-app-state-pillar.md) - IME's authoritative per-app state read straight from the registry (Win32Apps EnforcementStateMessage, EspTrackingWin32Apps, StatusServiceReports) via a snapshot-and-diff watcher; registry/log reconciliation doubles as the built-in IME log-pattern drift alarm. * [ESP Policy-Provider Stall Detection](agent/esp-policy-provider-stall.md) - The EnrollmentStatusTracking CSP's no-timeout Setup/Apps wait is keyed to the Intune-registered "Sidecar" provider by NAME: co-management's "ConfigMgr" parks the user ESP at "Apps (Identifying)" for days even with TrackingPoliciesCreated=1 (issue #106); the always-on two-arm esp_policy_provider_stalled tripwire (provider_incomplete | sidecar_provider_missing) and the SOFTWARE\Microsoft\Windows\Autopilot gather-rule allowlist entry that surface it. # Rules diff --git a/docs/log.md b/docs/log.md index 02615cb8..8ede69da 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,9 @@ # Log +## 2026-08-18 + +* **New**: `agent/registry-app-state-pillar.md` — the registry second pillar for IME app state (audit follow-up): snapshot-and-diff watcher on `HKLM\...\IntuneManagementExtension` (Win32Apps / EspTrackingWin32Apps / StatusServiceReports), `registry_app_state` + `app_state_reconciliation` events, silent startup baseline, WDP reuse notes. + ## 2026-08-17 * **Update**: `mcp/tool-telemetry.md` — the `tool_call` stderr line now carries `errorMessage` (capped at 300 chars; thrown message or the leading `toolError` text, absent on success), so error drilldowns name the failure class instead of guessing from args. Same change: `get_rule_stats` omits the per-rule daily `trend` rows by default (they drove 50% of calls over the response cap at platform scope); `includeTrends=true` opts back in, the response carries a `trendsNote` marker when stripped. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs new file mode 100644 index 00000000..db5ca353 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/SystemSignals/ImeRegistryAppStateObserverTests.cs @@ -0,0 +1,264 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Linq; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.Agent.V2.Core.Tests.Harness; +using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using Xunit; +using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; + +namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals +{ + /// + /// Registry second pillar (audit 2026-08-17): pure parsing/diff logic plus the + /// tick flow (baseline silence, state-change emission, reconciliation dwell) + /// driven through the snapshot override — no live registry. + /// + public sealed class ImeRegistryAppStateObserverTests + { + private const string App1 = "11111111-1111-1111-1111-111111111111"; + private const string App2 = "22222222-2222-2222-2222-222222222222"; + private static readonly DateTime T0 = new DateTime(2026, 8, 18, 8, 0, 0, DateTimeKind.Utc); + + // ── pure helpers ──────────────────────────────────────────────────────── + + [Theory] + [InlineData("11111111-1111-1111-1111-111111111111_1", "11111111-1111-1111-1111-111111111111")] + [InlineData("11111111-1111-1111-1111-111111111111", "11111111-1111-1111-1111-111111111111")] + [InlineData("GRS", null)] + [InlineData("", null)] + [InlineData("not-a-guid_1", null)] + public void ExtractAppId_parses_app_keys_and_rejects_non_app_keys(string keyName, string? expected) + => Assert.Equal(expected, ImeRegistryAppStateObserver.ExtractAppId(keyName)); + + [Fact] + public void ParseEnforcementStateMessage_reads_state_and_error() + { + var (state, error) = ImeRegistryAppStateObserver.ParseEnforcementStateMessage( + "{\"EnforcementState\":1000,\"ErrorCode\":0}"); + Assert.Equal(1000, state); + Assert.Equal(0L, error); + + Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("not json")); + Assert.Equal((null, null), ImeRegistryAppStateObserver.ParseEnforcementStateMessage("[]")); + } + + [Theory] + [InlineData(1000, "success")] + [InlineData(1004, "success")] + [InlineData(2009, "inProgress")] + [InlineData(3000, "requirementsNotMet")] + [InlineData(5003, "error")] + [InlineData(6001, "notAttempted")] + [InlineData(42, "unknown")] + public void ClassifyEnforcementState_bands(int state, string expected) + => Assert.Equal(expected, ImeRegistryAppStateObserver.ClassifyEnforcementState(state)); + + [Fact] + public void DiffSnapshots_reports_only_changed_fields() + { + var prev = Snapshot((App1, 2009, null)); + var next = Snapshot((App1, 1000, 0), (App2, 2000, null)); + + var changes = ImeRegistryAppStateObserver.DiffSnapshots(prev, next); + + Assert.Equal(2, changes.Count); + var app1Change = changes.Single(c => c.Entry.AppId == App1); + Assert.Contains("enforcementState", app1Change.ChangedFields); + Assert.Contains("errorCode", app1Change.ChangedFields); + Assert.False(app1Change.IsNew); + Assert.True(changes.Single(c => c.Entry.AppId == App2).IsNew); + + Assert.Empty(ImeRegistryAppStateObserver.DiffSnapshots(next, next)); + } + + [Fact] + public void IsDivergent_rules() + { + var installed = PackageState(App1, AppInstallationState.Installed); + var errored = PackageState(App1, AppInstallationState.Error); + + Assert.True(ImeRegistryAppStateObserver.IsDivergent("error", installed, true, out var r1)); + Assert.Equal("registry_error_log_installed", r1); + Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", errored, true, out var r2)); + Assert.Equal("registry_success_log_error", r2); + Assert.True(ImeRegistryAppStateObserver.IsDivergent("success", null, true, out var r3)); + Assert.Equal("app_unknown_to_log_tracking", r3); + // Tracker idle (no apps at all) — not judgeable, no false alarm. + Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", null, false, out _)); + // Agreement. + Assert.False(ImeRegistryAppStateObserver.IsDivergent("success", installed, true, out _)); + Assert.False(ImeRegistryAppStateObserver.IsDivergent("error", errored, true, out _)); + } + + // ── tick flow ─────────────────────────────────────────────────────────── + + [Fact] + public void Baseline_is_silent_then_changes_emit_state_events() + { + var (sink, clock, observer) = CreateObserver(trackerApps: null); + var current = Snapshot((App1, 2000, null)); + + using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current)) + { + observer.Tick("baseline"); + Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState)); + + current = Snapshot((App1, 1000, 0)); + observer.Tick("registry_change"); + } + + var evt = Assert.Single(Events(sink, SharedEventTypes.RegistryAppState)); + Assert.Equal(App1, evt.Payload!["appId"]); + Assert.Equal("1000", evt.Payload["enforcementState"]); + Assert.Equal("success", evt.Payload["enforcementClass"]); + } + + [Fact] + public void Unchanged_snapshot_emits_nothing() + { + var (sink, clock, observer) = CreateObserver(trackerApps: null); + var current = Snapshot((App1, 2000, null)); + + using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current)) + { + observer.Tick("baseline"); + observer.Tick("periodic"); + observer.Tick("periodic"); + } + + Assert.Empty(Events(sink, SharedEventTypes.RegistryAppState)); + } + + [Fact] + public void Reconciliation_fires_once_after_settle_delay_on_divergence() + { + var tracker = new List { PackageState(App1, AppInstallationState.Installed) }; + var (sink, clock, observer) = CreateObserver(trackerApps: tracker); + var current = Snapshot((App1, 2000, null)); + + using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current)) + { + observer.Tick("baseline"); + current = Snapshot((App1, 5003, 101)); // registry error, log says Installed + observer.Tick("registry_change"); + + // Before the settle delay: no reconciliation yet. + Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation)); + + clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1)); + observer.Tick("periodic"); + observer.Tick("periodic"); // second pass must not re-emit + } + + var rec = Assert.Single(Events(sink, SharedEventTypes.AppStateReconciliation)); + Assert.Equal("error", rec.Payload!["registryOutcome"]); + Assert.Equal("registry_error_log_installed", rec.Payload["reason"]); + Assert.Equal("Installed", rec.Payload["logState"]); + } + + [Fact] + public void Reconciliation_skips_agreeing_and_baseline_only_apps() + { + var tracker = new List { PackageState(App1, AppInstallationState.Installed) }; + var (sink, clock, observer) = CreateObserver(trackerApps: tracker); + + // App2 is terminal in the BASELINE (pre-existing from an earlier enrollment) and + // never changes — it must never be judged. App1 changes and agrees with the log. + var current = Snapshot((App1, 2000, null), (App2, 5000, 1)); + using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current)) + { + observer.Tick("baseline"); + current = Snapshot((App1, 1000, 0), (App2, 5000, 1)); + observer.Tick("registry_change"); + + clock.Advance(ImeRegistryAppStateObserver.ReconcileSettleDelay + TimeSpan.FromSeconds(1)); + observer.Tick("periodic"); + } + + Assert.Empty(Events(sink, SharedEventTypes.AppStateReconciliation)); + } + + [Fact] + public void State_event_cap_emits_single_warning_then_suppresses() + { + var (sink, clock, observer) = CreateObserver(trackerApps: null); + + var baseline = new ImeRegistrySnapshot(); + var flooded = new ImeRegistrySnapshot(); + for (var i = 0; i < ImeRegistryAppStateObserver.MaxStateEventsPerSession + 25; i++) + { + var appId = Guid.NewGuid().ToString("D"); + baseline.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 2000; + flooded.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId).EnforcementState = 1000; + } + + var current = baseline; + using (new ImeRegistryAppStateObserver.ScopedSnapshotOverride(() => current)) + { + observer.Tick("baseline"); + current = flooded; + observer.Tick("registry_change"); + } + + var events = Events(sink, SharedEventTypes.RegistryAppState); + // Cap payload-carrying events + exactly one cap-notice Warning. + Assert.Equal(ImeRegistryAppStateObserver.MaxStateEventsPerSession + 1, events.Count); + Assert.Single(events, e => e.Payload != null + && e.Payload.TryGetValue(SignalPayloadKeys.Message, out var m) + && m.Contains("cap reached")); + } + + // ── harness ───────────────────────────────────────────────────────────── + + private static (FakeSignalIngressSink sink, VirtualClock clock, ImeRegistryAppStateObserver observer) + CreateObserver(IReadOnlyList? trackerApps) + { + var sink = new FakeSignalIngressSink(); + var clock = new VirtualClock(T0); + var post = new InformationalEventPost(sink, clock); + var observer = new ImeRegistryAppStateObserver( + post, + logger: null, + clock: clock, + trackerStateProbe: trackerApps == null ? null : () => trackerApps); + return (sink, clock, observer); + } + + private static ImeRegistrySnapshot Snapshot(params (string appId, int enforcementState, long? errorCode)[] apps) + { + var snapshot = new ImeRegistrySnapshot(); + foreach (var (appId, state, error) in apps) + { + var entry = snapshot.GetOrAdd(ImeRegistrySnapshot.DeviceContext, appId); + entry.EnforcementState = state; + entry.ErrorCode = error; + } + return snapshot; + } + + private static AppPackageState PackageState(string id, AppInstallationState state) + { + var pkg = new AppPackageState(id, listPos: 0); + if (state == AppInstallationState.Installed) + { + // Route through Installing first — a bare Installed would trip the + // inverse-detection auto-downgrade heuristic (Installed w/o activity -> Skipped). + pkg.UpdateState(AppInstallationState.Installing); + } + pkg.UpdateState(state); + return pkg; + } + + private static IReadOnlyList Events(FakeSignalIngressSink sink, string eventType) => + sink.Posted.Where(p => + p.Payload != null + && p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et) + && et == eventType).ToList(); + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs new file mode 100644 index 00000000..6e352463 --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Enrollment/SystemSignals/ImeRegistryAppStateObserver.cs @@ -0,0 +1,577 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text.Json; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Orchestration; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.Shared.Models; +using Microsoft.Win32; +using SharedEventTypes = AutopilotMonitor.Shared.Constants.EventTypes; + +namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals +{ + /// + /// Registry "second pillar" for IME app state (audit 2026-08-17, architecture decision: + /// registry = stable truth, IME logs = narrative). IME persists its authoritative per-app + /// state under HKLM\SOFTWARE\Microsoft\IntuneManagementExtension: + /// + /// Win32Apps\<userGuid>\<appGuid>_<rev> — EnforcementStateMessage JSON + /// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user. + /// EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev> — which apps IME + /// registered for ESP tracking and in which phase. + /// SideCarPolicies\StatusServiceReports\<userGuid>\<appId> — the exact + /// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed). + /// + /// The observer is snapshot-and-diff driven (RegistryWatcher gives key-scope edges only, + /// coalesced — a per-write parse is impossible by design): every tick re-reads the three + /// surfaces and emits registry_app_state on real field changes. Pre-existing state at + /// agent start is the silent baseline — Win32Apps keys survive re-enrollments, so replaying + /// them as fresh events would be the registry twin of the historic-IME-replay bug. + /// + /// Reconciliation (= built-in log-pattern drift alarm): once a registry entry that changed + /// during THIS session has been terminal for , its outcome + /// is compared against the log-derived . A divergence means the + /// IME log patterns missed or mis-classified the app — emitted once per app as + /// app_state_reconciliation (Warning). Pure observability: nothing here feeds the + /// DecisionEngine. + /// + internal sealed class ImeRegistryAppStateObserver + { + public const string SourceLabel = "RegistryAppState"; + + internal const string ImeRootKeyPath = @"SOFTWARE\Microsoft\IntuneManagementExtension"; + + // Safety cap: a pathological churn (or a wrong diff) must not flood the timeline. + // 200 covers ~25 apps x 8 state transitions; the cap itself is announced once. + internal const int MaxStateEventsPerSession = 200; + + // The log tracker legitimately lags the registry (log write + 100 ms poll + phase + // gating), so divergence is only evaluated after the registry state has been stable + // terminal for this long. + internal static readonly TimeSpan ReconcileSettleDelay = TimeSpan.FromSeconds(90); + + private readonly InformationalEventPost _post; + private readonly AgentLogger? _logger; + private readonly IClock _clock; + private readonly Func>? _trackerStateProbe; + private readonly object _sync = new object(); + + private ImeRegistrySnapshot? _last; + private readonly Dictionary _terminalSinceUtc = + new Dictionary(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _changedDuringSession = + new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _reconciliationEmitted = + new HashSet(StringComparer.OrdinalIgnoreCase); + private int _stateEventsEmitted; + private bool _capNoticeEmitted; + + // Test seam: replaces the live registry walk (same convention as EspTrackingInfoProbe). + internal static Func? SnapshotOverride; + + internal sealed class ScopedSnapshotOverride : IDisposable + { + private readonly Func? _previous; + private int _disposed; + + public ScopedSnapshotOverride(Func replacement) + { + _previous = SnapshotOverride; + SnapshotOverride = replacement; + } + + public void Dispose() + { + if (System.Threading.Interlocked.Exchange(ref _disposed, 1) != 0) return; + SnapshotOverride = _previous; + } + } + + public ImeRegistryAppStateObserver( + InformationalEventPost post, + AgentLogger? logger, + IClock clock, + Func>? trackerStateProbe) + { + _post = post ?? throw new ArgumentNullException(nameof(post)); + _clock = clock ?? throw new ArgumentNullException(nameof(clock)); + _logger = logger; + _trackerStateProbe = trackerStateProbe; + } + + /// + /// One observation pass: snapshot → diff → emit → reconcile. Called from the debounced + /// registry-change edge AND a periodic fallback tick (the settle-delay evaluation must + /// happen even when the registry goes quiet). Fail-soft: never throws. + /// + public void Tick(string reason) + { + try + { + lock (_sync) + { + var snapshot = SnapshotOverride?.Invoke() ?? ReadSnapshot(); + var nowUtc = _clock.UtcNow; + + if (_last == null) + { + // Silent baseline — see class doc. + _last = snapshot; + _logger?.Info($"RegistryAppState: baseline captured ({snapshot.Entries.Count} app entries, trigger={reason})"); + return; + } + + var changes = DiffSnapshots(_last, snapshot); + foreach (var change in changes) + { + _changedDuringSession.Add(change.Entry.Key); + EmitStateEvent(change, nowUtc); + } + + TrackTerminalTransitions(snapshot, nowUtc); + EvaluateReconciliations(snapshot, nowUtc); + + _last = snapshot; + } + } + catch (Exception ex) + { + _logger?.Debug($"RegistryAppState: tick failed (fail-soft): {ex.Message}"); + } + } + + // ── snapshot ──────────────────────────────────────────────────────────── + + internal static ImeRegistrySnapshot ReadSnapshot() + { + var snapshot = new ImeRegistrySnapshot(); + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, RegistryView.Registry64); + using var root = baseKey.OpenSubKey(ImeRootKeyPath); + if (root == null) return snapshot; + + ReadWin32Apps(root, snapshot); + ReadEspTracking(root, snapshot); + ReadStatusServiceReports(root, snapshot); + return snapshot; + } + + private static void ReadWin32Apps(RegistryKey root, ImeRegistrySnapshot snapshot) + { + using var win32Apps = root.OpenSubKey("Win32Apps"); + if (win32Apps == null) return; + + foreach (var userName in win32Apps.GetSubKeyNames()) + { + if (!LooksLikeGuid(userName)) continue; // skips OperationalState / Reporting / ProvisioningProgress + using var userKey = win32Apps.OpenSubKey(userName); + if (userKey == null) continue; + + foreach (var appKeyName in userKey.GetSubKeyNames()) + { + var appId = ExtractAppId(appKeyName); + if (appId == null) continue; // skips GRS + non-app keys + + using var appKey = userKey.OpenSubKey(appKeyName); + if (appKey == null) continue; + + var entry = snapshot.GetOrAdd(userName, appId); + entry.ExitCode = TryReadInt(appKey.GetValue("ExitCode")); + entry.Intent = TryReadInt(appKey.GetValue("Intent")); + + using var esm = appKey.OpenSubKey("EnforcementStateMessage"); + var json = esm?.GetValue("EnforcementStateMessage") as string; + if (!string.IsNullOrEmpty(json)) + { + var (state, error) = ParseEnforcementStateMessage(json!); + entry.EnforcementState = state; + entry.ErrorCode = error; + } + } + } + } + + private static void ReadEspTracking(RegistryKey root, ImeRegistrySnapshot snapshot) + { + using var esp = root.OpenSubKey("EspTrackingWin32Apps"); + if (esp == null) return; + + foreach (var userName in esp.GetSubKeyNames()) + { + if (!LooksLikeGuid(userName)) continue; + using var userKey = esp.OpenSubKey(userName); + if (userKey == null) continue; + + foreach (var appKeyName in userKey.GetSubKeyNames()) + { + var appId = ExtractAppId(appKeyName); + if (appId == null) continue; + + using var appKey = userKey.OpenSubKey(appKeyName); + var entry = snapshot.GetOrAdd(userName, appId); + entry.EspTracked = true; + entry.EspPhase = appKey?.GetValue("EspTrackingWin32AppPhase") as string; + } + } + } + + private static void ReadStatusServiceReports(RegistryKey root, ImeRegistrySnapshot snapshot) + { + using var reports = root.OpenSubKey(@"SideCarPolicies\StatusServiceReports"); + if (reports == null) return; + + foreach (var userName in reports.GetSubKeyNames()) + { + if (!LooksLikeGuid(userName)) continue; + using var userKey = reports.OpenSubKey(userName); + if (userKey == null) continue; + + foreach (var appKeyName in userKey.GetSubKeyNames()) + { + var appId = ExtractAppId(appKeyName); + if (appId == null) continue; + + using var appKey = userKey.OpenSubKey(appKeyName); + if (appKey == null) continue; + + var entry = snapshot.GetOrAdd(userName, appId); + entry.StatusServiceStatus = TryReadInt(appKey.GetValue("Status")); + } + } + } + + // ── pure helpers (unit-tested directly) ───────────────────────────────── + + internal static bool LooksLikeGuid(string name) => + !string.IsNullOrEmpty(name) && Guid.TryParse(name, out _); + + /// + /// Win32Apps app keys are <appGuid>_<revision>; StatusServiceReports keys + /// are the bare GUID. Returns the lowercase app GUID or null for non-app keys (GRS, ...). + /// + internal static string? ExtractAppId(string keyName) + { + if (string.IsNullOrEmpty(keyName)) return null; + var candidate = keyName.Length >= 36 ? keyName.Substring(0, 36) : keyName; + return Guid.TryParse(candidate, out var g) ? g.ToString("D") : null; + } + + internal static (int? state, long? errorCode) ParseEnforcementStateMessage(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + int? state = null; + long? error = null; + if (doc.RootElement.ValueKind == JsonValueKind.Object) + { + if (doc.RootElement.TryGetProperty("EnforcementState", out var s) && s.ValueKind == JsonValueKind.Number) + state = s.GetInt32(); + if (doc.RootElement.TryGetProperty("ErrorCode", out var e) && e.ValueKind == JsonValueKind.Number) + error = e.GetInt64(); + } + return (state, error); + } + catch + { + return (null, null); + } + } + + internal static int? TryReadInt(object? value) + { + switch (value) + { + case null: return null; + case int i: return i; + case long l: return unchecked((int)l); + case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed): + return parsed; + default: return null; + } + } + + /// StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104). + internal static string ClassifyEnforcementState(int state) + { + if (state >= 1000 && state < 2000) return "success"; + if (state >= 2000 && state < 3000) return "inProgress"; + if (state >= 3000 && state < 4000) return "requirementsNotMet"; + if (state >= 5000 && state < 6000) return "error"; + if (state >= 6000 && state < 7000) return "notAttempted"; + return "unknown"; + } + + /// AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed. + internal static string? ClassifyStatusServiceStatus(int status) + { + if (status >= 1000 && status < 2000) return "installed"; + if (status == 3000) return "failed"; + if (status >= 2000 && status < 3000) return "notApplicable"; + return null; + } + + internal static List DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next) + { + var changes = new List(); + foreach (var kv in next.Entries) + { + previous.Entries.TryGetValue(kv.Key, out var prev); + var changed = ChangedFields(prev, kv.Value); + if (changed.Count > 0) + changes.Add(new AppRegistryChange(kv.Value, changed, isNew: prev == null)); + } + return changes; + } + + internal static List ChangedFields(AppRegistryEntry? prev, AppRegistryEntry next) + { + var changed = new List(); + if (prev?.EnforcementState != next.EnforcementState) changed.Add("enforcementState"); + if (prev?.ErrorCode != next.ErrorCode) changed.Add("errorCode"); + if (prev?.ExitCode != next.ExitCode) changed.Add("exitCode"); + if (prev?.StatusServiceStatus != next.StatusServiceStatus) changed.Add("statusServiceStatus"); + if ((prev?.EspTracked ?? false) != next.EspTracked) changed.Add("espTracked"); + if (!string.Equals(prev?.EspPhase, next.EspPhase, StringComparison.OrdinalIgnoreCase)) changed.Add("espPhase"); + return changed; + } + + /// "success" / "error" when the registry outcome is terminal, else null. + internal static string? TerminalOutcome(AppRegistryEntry entry) + { + if (entry.EnforcementState is int es) + { + var cls = ClassifyEnforcementState(es); + if (cls == "success") return "success"; + if (cls == "error") return "error"; + } + if (entry.StatusServiceStatus is int ss) + { + var cls = ClassifyStatusServiceStatus(ss); + if (cls == "installed") return "success"; + if (cls == "failed") return "error"; + } + return null; + } + + /// + /// Divergence rules (observability, conservative — prefer false-negative over noise): + /// registry error vs log Installed, registry success vs log Error, or registry terminal + /// while the tracker (actively tracking other apps) never saw the app at all. + /// + internal static bool IsDivergent( + string registryOutcome, + AppPackageState? logState, + bool trackerHasAnyApps, + out string reasonCode) + { + if (logState == null) + { + if (trackerHasAnyApps) + { + reasonCode = "app_unknown_to_log_tracking"; + return true; + } + reasonCode = string.Empty; // tracker idle (e.g. pre-ESP) — not judgeable + return false; + } + + if (registryOutcome == "error" && logState.InstallationState == AppInstallationState.Installed) + { + reasonCode = "registry_error_log_installed"; + return true; + } + if (registryOutcome == "success" && logState.InstallationState == AppInstallationState.Error) + { + reasonCode = "registry_success_log_error"; + return true; + } + + reasonCode = string.Empty; + return false; + } + + // ── emission ──────────────────────────────────────────────────────────── + + private void EmitStateEvent(AppRegistryChange change, DateTime nowUtc) + { + if (_stateEventsEmitted >= MaxStateEventsPerSession) + { + if (!_capNoticeEmitted) + { + _capNoticeEmitted = true; + _post.Emit( + eventType: SharedEventTypes.RegistryAppState, + source: SourceLabel, + message: $"registry_app_state cap reached ({MaxStateEventsPerSession}) — further registry app-state changes are logged locally only", + severity: EventSeverity.Warning, + occurredAtUtc: nowUtc); + } + _logger?.Debug($"RegistryAppState: cap reached, suppressing {change.Entry.AppId}"); + return; + } + _stateEventsEmitted++; + + var e = change.Entry; + var data = new Dictionary(StringComparer.Ordinal) + { + ["appId"] = e.AppId, + ["userContext"] = e.UserContext, + ["changedFields"] = string.Join(",", change.ChangedFields), + }; + if (e.EnforcementState is int es) + { + data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture); + data["enforcementClass"] = ClassifyEnforcementState(es); + } + if (e.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture); + if (e.ExitCode is int xc) data["exitCode"] = xc.ToString(CultureInfo.InvariantCulture); + if (e.Intent is int intent) data["intent"] = intent.ToString(CultureInfo.InvariantCulture); + if (e.StatusServiceStatus is int ss) + { + data["statusServiceStatus"] = ss.ToString(CultureInfo.InvariantCulture); + var cls = ClassifyStatusServiceStatus(ss); + if (cls != null) data["statusServiceClass"] = cls; + } + if (e.EspTracked) data["espTracked"] = "true"; + if (!string.IsNullOrEmpty(e.EspPhase)) data["espPhase"] = e.EspPhase!; + + var summary = data.TryGetValue("enforcementClass", out var encls) ? encls + : data.TryGetValue("statusServiceClass", out var sscls) ? sscls + : "updated"; + + _post.Emit( + eventType: SharedEventTypes.RegistryAppState, + source: SourceLabel, + message: $"Registry app state: {e.AppId} ({(e.UserContext == ImeRegistrySnapshot.DeviceContext ? "device" : "user")}) -> {summary}", + severity: EventSeverity.Info, + data: data, + occurredAtUtc: nowUtc); + } + + private void TrackTerminalTransitions(ImeRegistrySnapshot snapshot, DateTime nowUtc) + { + foreach (var kv in snapshot.Entries) + { + if (!_changedDuringSession.Contains(kv.Key)) continue; // baseline state — not ours to judge + var outcome = TerminalOutcome(kv.Value); + if (outcome == null) + { + _terminalSinceUtc.Remove(kv.Key); // regressed to non-terminal — reset dwell + continue; + } + if (!_terminalSinceUtc.ContainsKey(kv.Key)) + _terminalSinceUtc[kv.Key] = nowUtc; + } + } + + private void EvaluateReconciliations(ImeRegistrySnapshot snapshot, DateTime nowUtc) + { + if (_trackerStateProbe == null) return; + + List? trackerStates = null; + foreach (var kv in _terminalSinceUtc.ToList()) + { + if (_reconciliationEmitted.Contains(kv.Key)) continue; + if (nowUtc - kv.Value < ReconcileSettleDelay) continue; + if (!snapshot.Entries.TryGetValue(kv.Key, out var entry)) continue; + + var outcome = TerminalOutcome(entry); + if (outcome == null) continue; + + trackerStates ??= (_trackerStateProbe() ?? Array.Empty()).ToList(); + var logState = trackerStates.FirstOrDefault(p => + string.Equals(p.Id, entry.AppId, StringComparison.OrdinalIgnoreCase)); + + if (!IsDivergent(outcome, logState, trackerStates.Count > 0, out var reasonCode)) + { + _reconciliationEmitted.Add(kv.Key); // agreed (or not judgeable) — done with this app + continue; + } + + _reconciliationEmitted.Add(kv.Key); + var data = new Dictionary(StringComparer.Ordinal) + { + ["appId"] = entry.AppId, + ["userContext"] = entry.UserContext, + ["registryOutcome"] = outcome, + ["reason"] = reasonCode, + ["logState"] = logState?.InstallationState.ToString() ?? "unknown", + }; + if (logState != null && !string.IsNullOrEmpty(logState.Name)) data["appName"] = logState.Name; + if (entry.EnforcementState is int es) data["enforcementState"] = es.ToString(CultureInfo.InvariantCulture); + if (entry.ErrorCode is long ec) data["errorCode"] = ec.ToString(CultureInfo.InvariantCulture); + + _post.Emit( + eventType: SharedEventTypes.AppStateReconciliation, + source: SourceLabel, + message: $"Registry/log divergence for app {entry.AppId}: registry={outcome}, log={data["logState"]} ({reasonCode}) — IME log patterns may have drifted", + severity: EventSeverity.Warning, + immediateUpload: true, + data: data, + occurredAtUtc: nowUtc); + + _logger?.Warning($"RegistryAppState: divergence for {entry.AppId} — registry={outcome}, log={data["logState"]} ({reasonCode})"); + } + } + } + + /// Immutable-ish snapshot of the three IME registry surfaces, keyed per user-context + app. + internal sealed class ImeRegistrySnapshot + { + public const string DeviceContext = "00000000-0000-0000-0000-000000000000"; + + public Dictionary Entries { get; } = + new Dictionary(StringComparer.OrdinalIgnoreCase); + + public AppRegistryEntry GetOrAdd(string userContext, string appId) + { + var normalizedUser = Guid.TryParse(userContext, out var g) ? g.ToString("D") : userContext; + var key = normalizedUser + "|" + appId; + if (!Entries.TryGetValue(key, out var entry)) + { + entry = new AppRegistryEntry(key, normalizedUser, appId); + Entries[key] = entry; + } + return entry; + } + } + + internal sealed class AppRegistryEntry + { + public AppRegistryEntry(string key, string userContext, string appId) + { + Key = key; + UserContext = userContext; + AppId = appId; + } + + public string Key { get; } + public string UserContext { get; } + public string AppId { get; } + public int? EnforcementState { get; set; } + public long? ErrorCode { get; set; } + public int? ExitCode { get; set; } + public int? Intent { get; set; } + public int? StatusServiceStatus { get; set; } + public bool EspTracked { get; set; } + public string? EspPhase { get; set; } + } + + internal sealed class AppRegistryChange + { + public AppRegistryChange(AppRegistryEntry entry, List changedFields, bool isNew) + { + Entry = entry; + ChangedFields = changedFields; + IsNew = isNew; + } + + public AppRegistryEntry Entry { get; } + public List ChangedFields { get; } + public bool IsNew { get; } + } +} diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs index 5765377f..fad73db1 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/DefaultComponentFactory.cs @@ -326,6 +326,20 @@ namespace AutopilotMonitor.Agent.V2.Core.Orchestration clock: clock, startupGate: _startupEventGate)); + // Registry second pillar (audit 2026-08-17): IME's authoritative per-app state from + // HKLM\...\IntuneManagementExtension (Win32Apps EnforcementStateMessage, + // EspTrackingWin32Apps, StatusServiceReports) via snapshot-and-diff. Emits + // registry_app_state on real changes and app_state_reconciliation when the + // registry outcome diverges from the IME-log-derived state (= built-in pattern-drift + // alarm). Always-on observability host; the tracker probe is read-only. + hosts.Add(new ImeRegistryAppStateHost( + sessionId: sessionId, + tenantId: tenantId, + logger: logger, + ingress: ingress, + clock: clock, + trackerStateProbe: () => imeLogHost.AllKnownPackageStates)); + // Deterministic update corroboration — compares the persisted OS build (CurrentBuild.UBR) // across agent restarts and emits os_build_changed when it differs (session 7443317c: // build jump proved an OOBE quality update the WU channel never showed). Must be added diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs new file mode 100644 index 00000000..30101e7e --- /dev/null +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Orchestration/Hosts/ImeRegistryAppStateHost.cs @@ -0,0 +1,149 @@ +#nullable enable +using System; +using System.Collections.Generic; +using System.Threading; +using AutopilotMonitor.Agent.V2.Core.Logging; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Interop; +using AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime; +using AutopilotMonitor.DecisionCore.Engine; +using AutopilotMonitor.DecisionCore.Signals; +using Microsoft.Win32; + +namespace AutopilotMonitor.Agent.V2.Core.Orchestration +{ + /// + /// Hosts the (registry second pillar, audit + /// 2026-08-17): one recursive on the IME root — all three + /// observed surfaces (Win32Apps, EspTrackingWin32Apps, SideCarPolicies\StatusServiceReports) + /// live under it — debounced into snapshot-and-diff ticks, plus a 60-s periodic fallback + /// tick. The fallback both evaluates the reconciliation settle-delay when the registry goes + /// quiet AND keeps the pillar alive if the watcher fails to arm (poll-only degraded mode, + /// announced once via collector_degraded). Always-on observability host, no config + /// gate — precedent: . + /// + internal sealed class ImeRegistryAppStateHost : ICollectorHost + { + public string Name => "ImeRegistryAppState"; + + private static readonly TimeSpan DebounceDelay = TimeSpan.FromSeconds(2); + private static readonly TimeSpan PeriodicInterval = TimeSpan.FromSeconds(60); + + private readonly ImeRegistryAppStateObserver _observer; + private readonly InformationalEventPost _post; + private readonly AgentLogger? _logger; + private readonly string _sessionId; + private readonly string _tenantId; + + private RegistryWatcher? _watcher; + private Timer? _debounceTimer; + private Timer? _periodicTimer; + private int _stopped; + + public ImeRegistryAppStateHost( + string sessionId, + string tenantId, + AgentLogger? logger, + ISignalIngressSink ingress, + IClock clock, + Func>? trackerStateProbe) + { + if (ingress == null) throw new ArgumentNullException(nameof(ingress)); + if (clock == null) throw new ArgumentNullException(nameof(clock)); + _sessionId = sessionId; + _tenantId = tenantId; + _logger = logger; + _post = new InformationalEventPost(ingress, clock); + _observer = new ImeRegistryAppStateObserver(_post, logger, clock, trackerStateProbe); + } + + public void Start() + { + // Baseline immediately: pre-existing registry state must be captured silently + // BEFORE the first change edge, or stale entries would replay as fresh events. + _observer.Tick("baseline"); + + try + { + _watcher = new RegistryWatcher( + RegistryHive.LocalMachine, + ImeRegistryAppStateObserver.ImeRootKeyPath, + watchSubtree: true, + view: RegistryView.Registry64, + filter: RegistryNativeMethods.RegChangeNotifyFilter.Name + | RegistryNativeMethods.RegChangeNotifyFilter.LastSet + | RegistryNativeMethods.RegChangeNotifyFilter.ThreadAgnostic, + trace: msg => _logger?.Debug($"ImeRegistryAppState watcher: {msg}")); + _watcher.Changed += (_, __) => ScheduleDebouncedTick(); + _watcher.Error += (_, ex) => + { + _logger?.Warning($"ImeRegistryAppState watcher error (poll-only fallback continues): {ex.Message}"); + }; + _watcher.Start(); + } + catch (Exception ex) + { + // Watcher arm failure degrades to poll-only (periodic tick below) — announce once. + _logger?.Warning($"ImeRegistryAppState: watcher arm failed, running poll-only: {ex.Message}"); + CollectorDegradationReporter.Report(_post, _sessionId, _tenantId, Name, "watcher_arm_failed", ex); + _watcher?.Dispose(); + _watcher = null; + } + + _periodicTimer = new Timer(_ => SafeTick("periodic"), null, PeriodicInterval, PeriodicInterval); + _logger?.Info($"ImeRegistryAppStateHost started (watcher={(_watcher != null ? "armed" : "poll-only")})"); + } + + private void ScheduleDebouncedTick() + { + if (Volatile.Read(ref _stopped) != 0) return; + // One trailing-edge tick per change burst; RegistryWatcher coalesces anyway and the + // observer snapshot-diffs, so collapsing bursts loses nothing. + var timer = _debounceTimer; + if (timer == null) + { + var created = new Timer(_ => SafeTick("registry_change"), null, DebounceDelay, Timeout.InfiniteTimeSpan); + if (Interlocked.CompareExchange(ref _debounceTimer, created, null) != null) + created.Dispose(); + else + return; + timer = _debounceTimer; + } + try { timer?.Change(DebounceDelay, Timeout.InfiniteTimeSpan); } + catch (ObjectDisposedException) { /* stopping */ } + } + + private void SafeTick(string reason) + { + if (Volatile.Read(ref _stopped) != 0) return; + try + { + _observer.Tick(reason); + } + catch (Exception ex) + { + // Observer.Tick is itself fail-soft; this is the last-resort belt. + _logger?.Debug($"ImeRegistryAppState tick failed: {ex.Message}"); + } + } + + public void Stop() + { + if (Interlocked.Exchange(ref _stopped, 1) != 0) return; + + // Never Stop() the watcher from inside its own callback — we're outside here, + // but RequestStop first keeps the contract obvious. + try { _watcher?.RequestStop(); } catch { /* fail-soft */ } + try { _watcher?.Dispose(); } catch { /* fail-soft */ } + _watcher = null; + + _debounceTimer?.Dispose(); + _debounceTimer = null; + _periodicTimer?.Dispose(); + _periodicTimer = null; + } + + public void Dispose() => Stop(); + } +} diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 36d764fb..0e4d77eb 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -147,6 +147,8 @@ export const EVENT_TYPES_CATALOG = { 'agent_version_check', 'ime_agent_version', 'ime_token_failure', + 'registry_app_state', + 'app_state_reconciliation', 'ime_user_session_completed', 'ime_session_change', 'ime_process_exited', diff --git a/src/Shared/AutopilotMonitor.Shared/Constants.cs b/src/Shared/AutopilotMonitor.Shared/Constants.cs index 485d91bf..a70d0bf1 100644 --- a/src/Shared/AutopilotMonitor.Shared/Constants.cs +++ b/src/Shared/AutopilotMonitor.Shared/Constants.cs @@ -417,6 +417,11 @@ namespace AutopilotMonitor.Shared public const string EspPhaseChanged = "esp_phase_changed"; public const string ImeAgentVersion = "ime_agent_version"; public const string ImeTokenFailure = "ime_token_failure"; // Warning, once per distinct errorCode: IME token acquisition failed (IntuneTokenManager "Failed to get AAD token ... errorCode = N"). Complements IME-TOKEN-SUCCESS: an auth outage previously left every check-in silently doing nothing with no timeline evidence. + // Registry second pillar (ImeRegistryAppStateObserver, audit 2026-08-17): IME's authoritative + // per-app state read straight from HKLM\...\IntuneManagementExtension. Observability only — + // never consumed by the DecisionEngine. + public const string RegistryAppState = "registry_app_state"; // Info, state-change-only (snapshot-diff vs silent startup baseline; capped 200/session, cap announced once as Warning): per-app EnforcementState/ErrorCode/ExitCode/StatusService status/ESP-tracking from the registry. + public const string AppStateReconciliation = "app_state_reconciliation"; // Warning, once per app, immediate upload: registry terminal outcome (settled >= 90 s) diverges from the IME-log-derived app state — the built-in log-pattern drift alarm (registry = truth, logs = narrative). public const string ImeUserSessionCompleted = "ime_user_session_completed"; public const string DoTelemetry = "do_telemetry"; public const string AllAppsCompleted = "all_apps_completed"; diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts index fba7bd85..be36dfed 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts @@ -488,6 +488,7 @@ export const SHARED_MANIFEST = { "app_install_skipped", "app_install_started", "app_install_starved", + "app_state_reconciliation", "app_tracking_summary", "autologon_analysis", "autopilot_profile", @@ -603,6 +604,7 @@ export const SHARED_MANIFEST = { "realmjoin_resolved", "realmjoin_timeout", "reboot_triggered", + "registry_app_state", "remote_config_fetch_failed", "script_completed", "script_failed", diff --git a/src/Web/autopilot-monitor-web/utils/shared-manifests.json b/src/Web/autopilot-monitor-web/utils/shared-manifests.json index 32cdf16c..8fb5cdf4 100644 --- a/src/Web/autopilot-monitor-web/utils/shared-manifests.json +++ b/src/Web/autopilot-monitor-web/utils/shared-manifests.json @@ -484,6 +484,7 @@ "app_install_skipped", "app_install_started", "app_install_starved", + "app_state_reconciliation", "app_tracking_summary", "autologon_analysis", "autopilot_profile", @@ -599,6 +600,7 @@ "realmjoin_resolved", "realmjoin_timeout", "reboot_triggered", + "registry_app_state", "remote_config_fetch_failed", "script_completed", "script_failed",