Compare commits

..

20 Commits

Author SHA1 Message Date
Oliver Kieselbach 41898d745a updated commands 2026-08-18 01:33:44 +02:00
Oliver Kieselbach c05ea4d63b Registry second pillar: IME app state from HKLM with log reconciliation
P2 of the 2026-08-17 IME code audit. IME log wording is an unstable
contract (audit found 9 silently dead patterns, reworded lines, quote
drift, [DO TEL] removal); the registry is IME's internal product
contract - the ESP page and Intune reporting consume it.

ImeRegistryAppStateObserver + ImeRegistryAppStateHost (always-on
observability, no DecisionEngine coupling):
- one recursive RegistryWatcher on HKLM\...\IntuneManagementExtension
  covers Win32Apps (EnforcementStateMessage JSON, ExitCode, Intent),
  EspTrackingWin32Apps and SideCarPolicies\StatusServiceReports;
  2 s debounced snapshot-and-diff + 60 s periodic fallback tick
  (poll-only degraded mode if the watcher fails to arm)
- silent startup baseline: Win32Apps keys survive re-enrollments,
  pre-existing state never replays as fresh events
- registry_app_state (Info, state-change-only, 200/session cap with
  one-shot cap notice)
- app_state_reconciliation (Warning, once per app, immediate upload):
  a changed entry terminal for >= 90 s is compared against the
  log-derived AppPackageState - divergence is the built-in
  log-pattern drift alarm (registry = truth, logs = narrative)

Event types dual-registered (Constants + MCP resource-catalog + shared
manifests regenerated). OKF concept doc agent/registry-app-state-pillar.md
incl. WDP Paket-3 reuse notes. Tests: pure parse/diff/divergence logic +
tick flow via snapshot override (baseline silence, emission, dwell,
fire-once, cap). All suites green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 01:25:54 +02:00
Oliver Kieselbach 68eb2da283 IME tracker: tail AppActionProcessor.log; GRS + token-failure signals
P0b/P1 of the 2026-08-17 IME code audit (agent release required):

- Tail AppActionProcessor.log (+ rollover glob) in ImeLogTracker and the
  --run-ime-matching CLI. IME routes every [Win32App][ActionProcessor]
  line there (enforcement decision engine), so IME-NO-ACTION and
  IME-NOT-TARGETED-1/2 could never fire before, and GRS skips were
  invisible. Live session 0fe2c562 carried 16 no-action and 64
  classification lines in that file.

- New patterns (wordings verified in decompiled IME 1.97 AND 1.104):
  IME-GRS-SKIP ("is in GRS. The app will not be enforced." -> Postponed;
  THE canonical silent non-install reason), IME-GRS-CLEARED (early
  subgraph termination, retry next check-in -> Postponed),
  IME-TOKEN-FAILURE ("Failed to get AAD token ... errorCode = N").

- New action imeTokenFailure -> ime_token_failure Warning event (new
  EventType: Constants + MCP resource-catalog + shared manifests
  regenerated). Tracker dedups per distinct errorCode; included in
  the historic-replay guard so stale lines from a previous enrollment
  cannot emit a misleading warning. Complements IME-TOKEN-SUCCESS:
  an auth outage previously left check-ins silently doing nothing.

Tests: adapter emission + unknown-code + tracker dedup; known-actions
catalog extended; all suites green (Functions 4262, Agent 2252, web 762).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 01:01:02 +02:00
Oliver Kieselbach ab0f27b5ab Download times: event-window fallback for IME 1.104 (DO TEL removed)
IME 1.104 no longer logs the [DO TEL] line whose JSON carried the
DO-reported download duration - the Download Progress card lost all
times (per-app and total).

Durations now fall back to the observed event window (first download
event -> first completion-marking event) whenever the DO-reported value
is absent, and completed items show "in <duration>" inline. Works
retroactively for already-stored sessions, no agent release required;
the DO-reported value still wins when present (older IME versions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:40:53 +02:00
Oliver Kieselbach 32d8151185 updated .gitignore 2026-08-18 00:28:29 +02:00
Oliver Kieselbach f3749ccc16 updated commands 2026-08-18 00:24:17 +02:00
Oliver Kieselbach ca26cb4f7c backend: auto-archive IME installer on first sighting of a new version
When RecordImeVersionAsync reports a genuinely new IME version (~monthly),
EventIngestProcessor now also enqueues an ime-msi-archive job. The worker
(QueuePollingWorker, heartbeat, pause on ImeMsiArchivingEnabled=false)
downloads the installer and stores it write-once (If-None-Match:*) in the
permanent ime-archive container ({version}/IntuneWindowsAgent.msi +
provenance.json), then merges MsiArchiveStatus/BlobPath/Sha256/Bytes/SourceUrl
onto the ImeVersionHistory row - so every fleet-observed IME build stays
available for decompilation/diffing after Microsoft's versionless CDN moves on.

The download URL comes from agent telemetry (msiDownloadUrl enrichment) and is
treated as untrusted: https-only *.manage.microsoft.com allowlist with exact
filename match, canonical versionless fallback, no redirects, size cap
(MaxImeMsiDownloadSizeMB, default 250, preflight + mid-stream) enforced by a
SHA-256-hashing pass-through stream. Archiver never throws; the worker retries
only transient statuses (download/timeout/error), permanent rejections
complete with their Failed:* status persisted.

Admin settings ImeMsiArchivingEnabled (default true) + MaxImeMsiDownloadSizeMB
via the full 4-file web chain; archive fields on the version-history API are
GA-only (non-GA projection unchanged, redaction tests extended). Shared
manifests regenerated. Tests: archiver status branches via recording subclass
(allowlist, caps, 409 idempotency, cancellation propagation), config defaults.
Backend suite 4262 green, web tsc + vitest 762 green, solution 0 warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 00:20:53 +02:00
Oliver Kieselbach 8ceab7a064 updated commands 2026-08-17 23:51:56 +02:00
Oliver Kieselbach ae85dc325c gitignore: exclude local ime-files/ working directory
Machine-local reference files, kept out of version control.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 23:51:08 +02:00
Oliver Kieselbach aeb2be6d64 agent: enrich ime_agent_version with CSP install source (CurrentDownloadUrl)
The EnterpriseDesktopAppManagement CSP records every MSI it enforces under
HKLM\SOFTWARE\Microsoft\EnterpriseDesktopAppManagement\<SID>\MSI\{ProductCode},
including CurrentDownloadUrl and ProductVersion. New ImeMsiInstallSourceProbe
resolves the entry matching the IME version parsed from the IME log (normalized
ProductVersion match first, IntuneWindowsAgent.msi filename fallback) and the
adapter attaches msiDownloadUrl / msiProductCode / msiProductVersion /
msiMatchedBy to the ime_agent_version event - answering which CDN/AFD endpoint
delivered exactly that IME build. Fail-soft, cached per version, additive keys
only (no new event type). Tests: probe selection/normalization + adapter
enrichment; assembly-wide probe default keeps dev machines deterministic.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 23:29:37 +02:00
Oliver Kieselbach 0474223290 Diagnostics ZIP: in-archive packaging manifest
The agent-log snapshot is zipped BEFORE packaging finishes, so packaging
problems (blocked paths, silent no-match patterns, locked files, evtx
export errors) were invisible in the uploaded archive - twice this made a
missing evtx undiagnosable (sessions a11102f4/3ae7528b). Every packaging
decision is now recorded in package-manifest.txt inside the ZIP: ADDED,
NO MATCH, BLOCKED (path guard), SKIPPED (caps/reparse/no user session),
FAILED (copy), EVTX EXPORTED/ERROR/TIMEOUT, plus each configured
additional path with its expanded folder+pattern. Written unconditionally
so absence of the file identifies a pre-feature archive.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 23:04:21 +02:00
Oliver Kieselbach 1bdb71be59 evtx export: resolve channel via LogFilePath, never by naming convention
The %4-encodes-slash file naming is a convention, not a contract: the
Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider
channel writes plain BootstrapperAgentServiceLogProvider.evtx. Deriving the
channel from the file name therefore produced an invalid wevtutil target
(and the wrong advice to rename the tenant diag path cost a WDP test
cycle). The channel is now resolved by scanning registered channels for a
matching LogFilePath, with the %4 convention only as fallback. A failed
export no longer skips the file - it falls through to the raw copy, which
succeeds for inactive/archived evtx files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:48:19 +02:00
Oliver Kieselbach ccf4f2e054 IME patterns: quote-flexible report deltas for IME 1.104 serialization
Live validation against session 0fe2c562 (IME 1.104.102, first session on
the repaired pattern set) showed all report-delta patterns at zero: IME
1.104 logs the delta JSON with single quotes ({"EnforcementState":...)
where older versions logged doubled quotes ({""EnforcementState"":...).

Every literal quote in the seven delta patterns (DETECTED-INSTALLED-1,
ENFORCEMENT-SUCCESS, ERROR-DOWNLOAD, ERROR-REPORT, NOT-APPLICABLE-2/3/4)
is now matched as "{1,2} to cover both serializations.

Verified against the session's AppWorkload.log via validate-ime-patterns:
ENFORCEMENT-SUCCESS 12 (was 0), ERROR-REPORT 14 (was 0); no CRITICALs;
the 2026-08-17 repair set confirmed live (ESP-TRACK-STATUS 24,
ESP-APP-REGISTERED 12, CACHE-MISS 11 in the agent's own match log).

Also observed in 1.104, tracked separately (no pattern change possible):
[DO TEL] line removed entirely - the DO download-duration display feeds
on it; needs an agent-side replacement (DO collector enrichment).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:43:32 +02:00
Oliver Kieselbach d192a8e0fa fix(agent): token-aware runtime launch - Scheduled Task first on stripped MSI tokens
The MDM LOB Bootstrap-MSI chain (EnterpriseDesktopAppManagement CSP ->
msiexec -> runner -> bootstrap -> --install) carries a SYSTEM token with
SeTimeZonePrivilege and SeSystemEnvironmentPrivilege REMOVED. WMI
Win32_Process.Create duplicates the caller token onto the runtime, so
every MSI-delivered agent ran degraded for its whole life: tzutil exit 5
(ACCESS_DENIED) on timezone auto-set and privilege_denied on SEC-001
firmware reads (observed 2026-08-17, sessions a11102f4/0fe2c562/3ae7528b
vs. platform-script sessions 7b072755/c4c1c2d5 on the same binary).

Install mode now probes its own token (TokenPrivilegeProbe, fail-soft to
"held"): with a full token the proven WMI-first chain is unchanged; on a
stripped token the order flips to schtasks /Run first - the Task
Scheduler service starts the task with a fresh full SYSTEM token - with
a 10s process-verification poll (schtasks only reports "queued", the
2026-04-30 silent-failure lesson), WMI as degraded-but-alive fallback,
BootTrigger defer as the final net (full token there anyway).

Also: MSI runner log encoding - PS 5.1 *>> redirection wrote UTF-16LE
into the Add-Content (ANSI) msi-bootstrap.log, garbling every bootstrap
line; script output now routes through the same Add-Content pipeline.
Runner fix needs an MSI rebuild (wix build) to ship.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:39:39 +02:00
Oliver Kieselbach 06d92460ed Dashboard session rows: native open-in-new-tab via invisible cell link overlays
Rows were plain <tr onClick> so the browser offered no context-menu entry.
Each non-interactive cell now contains an invisible absolutely-positioned
Link (prefetch off) giving a real href for right-click/middle/ctrl+click;
visuals and SPA click behavior are unchanged. Cells with own buttons or
title tooltips (status, tenantId, actions) keep the row-click fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:37:48 +02:00
Oliver Kieselbach fc3900429c Security: IME log pattern PUT must be GlobalAdminOnly
PUT rules/ime-log-patterns/{patternId} sat on the TenantAdminOrGA tier
while the handler only checks ?global=true and writes the GLOBAL pattern
with no GA re-gate - any customer tenant admin could mutate platform-wide
patterns via direct API call. The UI gate (isGlobalAdmin && globalAdminMode)
was cosmetic only.

Moved the entry to GlobalAdminOnly next to the reseed route and added a
regression test pinning both IME pattern write routes to that tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 22:09:35 +02:00
Oliver Kieselbach 0ffcb2fde2 command update 2026-08-17 21:44:26 +02:00
Oliver Kieselbach a3089753d3 IME patterns: repair drift found by decompiled-source audit (IME 1.97 Dec-25)
Audit vs decompiled IME 1.97.107: ~55/79 patterns verified verbatim, but
several were broken or dead against the current V3 pipeline.

Fixes:
- IME-ESP-TRACK-STATUS: drop trailing anchor (line grew ' with name: <n>.')
- HS-COMPLIANCE: tolerate both 'remdiation' typo and corrected wording;
  new HS-COMPLIANCE-POST for the reworded post-detection line
- IME-DETECTION-RESULT: capture DetectedDifferentVersion (ordered first)
- IME-ENFORCEMENT-SUCCESS: unpin OldValue (Error/PendingReboot/null paths)
- IME-ERROR-REPORT: allow unquoted null OldValue on first write
- Disable 11 dead patterns (emitters removed with IME's old V1/V2 pipeline;
  IME-DETECTED-OLD additionally collided with IME-SKIP-INSTALL-DETECTED)

New signals (existing actions, tailed files only):
- IME-ESP-APP-REGISTERED + IME-CACHE-MISS as app-name sources (replacing
  dead IME-UPDATE-NAME-1/2/3)
- IME-WINGET-ERROR (WinGet failures were invisible)
- IME-EXITCODE-MAPPED-FAILED (normal mapped-exit-code failure path)
- IME-STOP-SIGNAL (service stop != OS shutdown)
- IME-RESTART-STOP-PROCESSING (reboot-gated processing abort)

Backend seed diff now also compares Enabled + Parameters - enable/disable
flips and parameter changes previously never propagated to the table.

All exact wordings verified against decompiled source; regex smoke-tested
via validate-ime-patterns.py (engine=regex, 75/75 compiled, all new
patterns fire on synthetic lines with the exact IME format strings).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:33:37 +02:00
Oliver Kieselbach 26cbc897c5 WDP: unify enrollment_type_detected with detector + export locked evtx
Two fixes from the a11102f4 diag analysis:

- enrollment_type_detected re-implemented the old CloudAssigned*-only
  detection and kept reporting "Autopilot v1 (Classic ESP)" on WDP devices
  while the session row already said v2. DeviceInfoCollector now uses
  EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule() - the same
  detector as registration - and the event names the rule that fired.
- Tenant-configured .evtx paths never made it into the diagnostics ZIP:
  active event-log channels hold their file exclusively locked. The
  package service now exports the channel via wevtutil epl (channel name
  derived from the %4-encoded file name) and streams the temp export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 21:28:43 +02:00
Oliver Kieselbach c88629dcf2 Bootstrap MSI: thin MDM LOB delivery vehicle for WDP
WDP batch order is fixed (User -> SLDM/MDM apps -> Scripts -> Win32Apps),
so a WDP-policy script can never observe the app phase live, and a hard
Batch-1 failure leaves zero visibility. LOB MSIs install via OMA-DM during
the first sync - before the SLDM batch - making them the earliest delivery
channel. The MSI carries no logic: it installs one fail-soft runner script
that downloads and executes the CURRENT server-hosted
Install-AutopilotMonitor.ps1, so all real bootstrap logic stays updatable
without re-shipping the MSI. Skips via the Deployed regkey when another
channel won the race. Build: wix build Package.wxs -arch x64 (WiX 5).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 20:35:37 +02:00
91 changed files with 4220 additions and 181 deletions
+1
View File
@@ -158,3 +158,4 @@ docs/~$Introduction-AutopilotMonitor.pptx
# Web build stamp: generated by scripts/generate-version.js on every build (buildUtc
# changes each run) and served at /version.json from the static export.
src/Web/autopilot-monitor-web/public/version.json
+89
View File
@@ -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\<userGuid>\<appGuid>_<rev>` | `EnforcementStateMessage` JSON (EnforcementState + ErrorCode), `ExitCode`, `Intent`. Device context = `Guid.Empty` user. Non-GUID subkeys (GRS, OperationalState, Reporting, ProvisioningProgress) are skipped. |
| `EspTrackingWin32Apps\<userGuid>\<appGuid>_<rev>` | Which apps IME registered for ESP tracking, `EspTrackingWin32AppPhase`. |
| `SideCarPolicies\StatusServiceReports\<userGuid>\<appId>` | 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\<version>\decompiled`
+1
View File
@@ -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
+4
View File
@@ -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.
+134 -42
View File
@@ -4,12 +4,23 @@
{
"patternId": "HS-COMPLIANCE",
"category": "always",
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": {
"scriptType": "remediation"
},
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
"enabled": true
},
{
"patternId": "HS-COMPLIANCE-POST",
"category": "always",
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": {
"scriptType": "remediation"
},
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
"enabled": true
},
{
@@ -139,6 +150,14 @@
"description": "Captures the install attempt number for the current app. IME logs 'Execute retry 0' for the first attempt and increments on each retry. Reported as attempt+1 so retry 0 becomes AttemptNumber=1 (human-friendly: first attempt).",
"enabled": true
},
{
"patternId": "IME-CACHE-MISS",
"category": "always",
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
"action": "updateName",
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
"enabled": true
},
{
"patternId": "IME-DECRYPT-FAILED",
"category": "currentPhase",
@@ -150,9 +169,9 @@
{
"patternId": "IME-DETECTED-INSTALLED-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
@@ -168,15 +187,15 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID} ",
"action": "updateStateInstalled",
"description": "Detects when an app is detected as installed (old log format).",
"enabled": true
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
"enabled": false
},
{
"patternId": "IME-DETECTION-RESULT",
"category": "always",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"action": "captureDetectionResult",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"enabled": true
},
{
@@ -217,9 +236,9 @@
{
"patternId": "IME-ENFORCEMENT-SUCCESS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
@@ -236,9 +255,9 @@
{
"patternId": "IME-ERROR-DOWNLOAD",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
"action": "updateStateError",
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
@@ -266,12 +285,12 @@
{
"patternId": "IME-ERROR-REPORT",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"action": "updateStateError",
"parameters": {
"checkTo": "true"
},
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
@@ -293,6 +312,14 @@
"description": "Detects when an app installer returned an exit code that has no mapping configured in Intune.",
"enabled": true
},
{
"patternId": "IME-ESP-APP-REGISTERED",
"category": "always",
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
"action": "updateName",
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
"enabled": true
},
{
"patternId": "IME-ESP-PHASE",
"category": "always",
@@ -304,9 +331,9 @@
{
"patternId": "IME-ESP-TRACK-STATUS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
"action": "espTrackStatus",
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
"enabled": true
},
{
@@ -320,6 +347,33 @@
"description": "Captures the installer exit code (lpExitCode) for the current app.",
"enabled": true
},
{
"patternId": "IME-EXITCODE-MAPPED-FAILED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
"action": "updateStateError",
"parameters": {
"useCurrentApp": "true"
},
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
"enabled": true
},
{
"patternId": "IME-GRS-CLEARED",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
"action": "updateStatePostponed",
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
"enabled": true
},
{
"patternId": "IME-GRS-SKIP",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
"action": "updateStatePostponed",
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
"enabled": true
},
{
"patternId": "IME-HRESULT",
"category": "currentPhase",
@@ -377,25 +431,25 @@
{
"patternId": "IME-NOT-APPLICABLE-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
"patternId": "IME-NOT-APPLICABLE-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
"patternId": "IME-NOT-APPLICABLE-4",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
},
{
@@ -422,6 +476,17 @@
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
"enabled": true
},
{
"patternId": "IME-RESTART-STOP-PROCESSING",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
"action": "updateStatePostponed",
"parameters": {
"useCurrentApp": "true"
},
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
"enabled": true
},
{
"patternId": "IME-SESSION-CHANGE",
"category": "always",
@@ -435,24 +500,24 @@
"category": "always",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
"action": "setCurrentApp",
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
"enabled": true
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SET-CURRENT-2",
"category": "always",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
"action": "setCurrentApp",
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
"enabled": true
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SET-CURRENT-3",
"category": "always",
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
"action": "setCurrentApp",
"description": "Detects the start of app presentation step in IME, setting the app as current.",
"enabled": true
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SET-CURRENT-4",
@@ -478,24 +543,24 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
"enabled": true
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SKIP-DEPENDENCY-DETECT",
"category": "currentPhase",
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
"action": "updateStateSkipped",
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
"enabled": true
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SKIP-DETECTED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
"action": "updateStateSkipped",
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
"enabled": true
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-SKIP-FILTERS",
@@ -529,8 +594,8 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
"action": "updateStateSkipped",
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
"enabled": true
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-STARTED",
@@ -540,6 +605,17 @@
"description": "Detects when the Intune Management Extension (IME) agent starts up.",
"enabled": true
},
{
"patternId": "IME-STOP-SIGNAL",
"category": "currentPhase",
"pattern": "EMS Agent received stop signal",
"action": "updateStatePostponed",
"parameters": {
"useCurrentApp": "true"
},
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
"enabled": true
},
{
"patternId": "IME-SUBGRAPH",
"category": "currentPhase",
@@ -548,6 +624,14 @@
"description": "Detects when V3Processor starts a new subgraph, cancelling any stuck app and setting the new app as current.",
"enabled": true
},
{
"patternId": "IME-TOKEN-FAILURE",
"category": "always",
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"action": "imeTokenFailure",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
"enabled": true
},
{
"patternId": "IME-TOKEN-SUCCESS",
"category": "always",
@@ -564,24 +648,24 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
"action": "updateName",
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
"enabled": true
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-UPDATE-NAME-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
"action": "updateName",
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
"enabled": true
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-UPDATE-NAME-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
"action": "updateName",
"description": "Captures the app name from the Processing app log entry.",
"enabled": true
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
},
{
"patternId": "IME-USER-SESSION-COMPLETED",
@@ -607,6 +691,14 @@
"description": "Captures the Win32 app ESP state (e.g., Installed, InProgress) for a specific app.",
"enabled": true
},
{
"patternId": "IME-WINGET-ERROR",
"category": "currentPhase",
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
"action": "updateStateError",
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
"enabled": true
},
{
"patternId": "IME-WINGET-INSTALLING-1",
"category": "currentPhase",
@@ -0,0 +1,10 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-COMPLIANCE-POST",
"category": "always",
"pattern": "\\[HS\\] the (?<part>post) detection script result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": { "scriptType": "remediation" },
"description": "Post-remediation detection result, current wording. IME 1.97 (Dec 2025) replaced the old '[HS] the post-remdiation detection script compliance result for ...' line (still covered by HS-COMPLIANCE for older logs) with '[HS] the post detection script result for <guid> is <True|False>' (ScriptProcessorV2.cs:721). The literal word 'post' in the line is captured as the part group so the handler classifies it as the post-detection phase.",
"enabled": true
}
+2 -2
View File
@@ -2,9 +2,9 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "HS-COMPLIANCE",
"category": "always",
"pattern": "\\[HS\\] the (?<part>pre|post)-remdiation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"pattern": "\\[HS\\] the (?<part>pre|post)-rem(?:e)?diation detection script compliance result for (?<id>[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}) is (?<compliance>True|False)",
"action": "healthScriptDetectionResult",
"parameters": { "scriptType": "remediation" },
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring).",
"description": "Early-signal fallback for health-script detection / post-detection results. Microsoft IME emits this line immediately after each detection script runs, ~30-90 seconds before the consolidated [HS] new result = JSON line is logged (which only fires after IME's batch-send to the Microsoft service). Short Autopilot enrollments often end inside that gap, so this pattern guarantees at least the compliance verdict is captured. The full data version arrives later via HS-NEW-RESULT and replaces this sparse entry through the UI dedupe (dataCompleteness scoring). Tolerates both Microsoft's historical typo 'remdiation' and a future corrected 'remediation'. NOTE: IME 1.97 (Dec 2025) no longer emits the post- variant of this line; the post case is covered by HS-COMPLIANCE-POST.",
"enabled": true
}
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-CACHE-MISS",
"category": "always",
"pattern": "\\[Win32App\\] Content cache miss for app \\(id = {GUID}, name = (?<name>.*?)\\), start downloading",
"action": "updateName",
"description": "Captures 'Content cache miss for app (id = <guid>, name = <name>), start downloading...' (ContentManager.cs) as a secondary app-name source. Fires right before a real download begins, so it resolves names for exactly the apps that will produce download/install activity — a redundancy for IME-POLICIES-based naming after IME retired the old name-bearing processing lines.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DETECTED-INSTALLED-1",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DetectionState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"Installed\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Detection state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DetectionState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}Installed\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format).",
"description": "Detects when an app's detection state transitions to Installed (new ReportingManager format). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
+2 -2
View File
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID} ",
"action": "updateStateInstalled",
"description": "Detects when an app is detected as installed (old log format).",
"enabled": true
"description": "Detects when an app is detected as installed (old log format). DISABLED 2026-08-17: the standalone 'Detected app <guid> ' line no longer exists in IME 1.97.107; this regex instead matched the 'Detected app ... and intent is to install, skip applicability check' line, colliding with IME-SKIP-INSTALL-DETECTED and firing updateStateInstalled and updateStateSkipped on the same physical line (see tasks/ime-v1-code-audit-2026-08-17.md).",
"enabled": false
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-DETECTION-RESULT",
"category": "always",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>Detected|NotDetected)",
"pattern": "\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
"action": "captureDetectionResult",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ENFORCEMENT-SUCCESS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgressDownloadCompleted\"\",\"\"NewValue\"\":\"\"Success\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}\\w+\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
"action": "updateStateInstalled",
"description": "Detects when an app's enforcement state transitions from InProgressDownloadCompleted to Success.",
"description": "Detects when an app's enforcement state transitions to Success in an Execution state report delta. OldValue is deliberately unpinned: besides InProgressDownloadCompleted it can be Error (retry succeeding in a later check-in), InProgressPendingReboot (post-reboot completion) or null (first write) — the previously pinned InProgressDownloadCompleted missed those legitimate success paths (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-DOWNLOAD",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"InProgress\"\",\"\"NewValue\"\":\"\"ErrorDownloadingContent\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Download state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgress\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}ErrorDownloadingContent\"{1,2}",
"action": "updateStateError",
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent.",
"description": "Detects when an app's enforcement state transitions to ErrorDownloadingContent. Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
+5 -3
View File
@@ -2,9 +2,11 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ERROR-REPORT",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"EnforcementState\"\":\\{\"\"OldValue\"\":\"\"(?<from>\\w+)\"\",\"\"NewValue\"\":\"\"(?<to>\\w+)\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
"action": "updateStateError",
"parameters": { "checkTo": "true" },
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state.",
"parameters": {
"checkTo": "true"
},
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ESP-APP-REGISTERED",
"category": "always",
"pattern": "\\[Win32App\\]\\[EspManager\\] In EspPhase: \\w+\\. App {GUID} has been registered.*?App name: (?<name>.*?)$",
"action": "updateName",
"description": "Captures the ESP tracked-app registration line ('App <guid> has been registered[ for user <id>]. App name: <name>', EspManager.cs) as an app-name source. Primary replacement for the retired IME-UPDATE-NAME-1/2/3 whose emitters were removed with IME's old V1/V2 processing pipeline; also documents exactly which apps IME registered for ESP tracking. Category 'always' so names resolve even before the first ApplicationPoller EspPhase line is seen.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-ESP-TRACK-STATUS",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}\\.",
"pattern": "\\[Win32App\\]\\[EspManager\\] Updating ESP tracked install status from (?<from>\\w+) to (?<to>\\w+) for application {GUID}",
"action": "espTrackStatus",
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app.",
"description": "Captures ESP tracked install status transitions (e.g., InProgress to Installed) for a specific app. No trailing anchor: IME 1.97 (Dec 2025) appends ' with name: <appName>.' after the GUID (EspManager.cs), which broke the previous '\\.' suffix. 'from' can be the literal string 'null' on first tracking.",
"enabled": true
}
@@ -0,0 +1,10 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-EXITCODE-MAPPED-FAILED",
"category": "currentPhase",
"pattern": "\\[Win32App\\] lpExitCode is defined as Failed$",
"action": "updateStateError",
"parameters": { "useCurrentApp": "true" },
"description": "Marks the current app as failed when its exit code maps to Failed via the admin-defined return-code table ('[Win32App] lpExitCode is defined as <Failed|Success|SoftReboot|HardReboot|Retry>', Win32AppInstaller.cs). This is the NORMAL mapped-exit-code outcome; the previously matched 'Setting enforcementState as ... Error' line only fires on the rare no-mapping fallback path. The numeric exit code is captured separately by IME-EXITCODE, which precedes this line.",
"enabled": true
}
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-GRS-CLEARED",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ExecutionActionHandler\\] Encountered early subgraph termination\\. Clearing GRS for app with id: {GUID} so it can be retried during the next check-in\\.",
"action": "updateStatePostponed",
"description": "IME aborted the app's subgraph before enforcement finished and cleared its GRS entry so the app retries on the next check-in (ExecutionActionHandler, AppWorkload.log). Maps to Postponed: the install did not complete this pass but is scheduled to run again - previously this looked like an unexplained gap. Wording verified in IME 1.97 and 1.104.",
"enabled": true
}
+9
View File
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-GRS-SKIP",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ActionProcessor\\] App with id: {GUID} to (?:install|uninstall) is in GRS\\. The app will not be enforced\\.",
"action": "updateStatePostponed",
"description": "GRS (Global Retry Schedule) skip: the app failed recently and IME will not re-enforce it this check-in ('App with id: <guid> to install is in GRS. The app will not be enforced.', EnforcementActionProcessor / MixedEnforcementActionProcessor). THE canonical reason an app silently does nothing for up to 24h - previously invisible. Lives in AppActionProcessor.log, which the agent tails since the 2026-08-18 release; wording verified in IME 1.97 and 1.104.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-2",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"ApplicabilityState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"ScriptRequirementRuleNotMet\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Applicability state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}ApplicabilityState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}ScriptRequirementRuleNotMet\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false).",
"description": "Detects when an app's applicability state is set to ScriptRequirementRuleNotMet (PowerShell requirement script returned false). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-3",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"None\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}None\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to None (not targeted or not applicable).",
"description": "Detects when an app's desired state is set to None (not targeted or not applicable). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
@@ -2,8 +2,8 @@
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-NOT-APPLICABLE-4",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"\"DesiredState\"\":\\{\"\"OldValue\"\":null,\"\"NewValue\"\":\"\"NotPresent\"\"",
"pattern": "\\[Win32App\\]\\[ReportingManager\\] Desired state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}DesiredState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:null,\"{1,2}NewValue\"{1,2}:\"{1,2}NotPresent\"{1,2}",
"action": "updateStateSkipped",
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required).",
"description": "Detects when an app's desired state is set to NotPresent (targeted for uninstall or not required). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
"enabled": true
}
@@ -0,0 +1,10 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-RESTART-STOP-PROCESSING",
"category": "currentPhase",
"pattern": "\\[Win32App\\]\\[V3Processor\\] A restart is required to continue processing apps\\. Stop processing\\.",
"action": "updateStatePostponed",
"parameters": { "useCurrentApp": "true" },
"description": "Detects IME aborting app processing because an app requires a restart ('A restart is required to continue processing apps. Stop processing.', SideCarV3AppProcessor.cs). Postpones the current app: remaining apps do nothing until after the reboot, which previously looked like an unexplained stall mid-ESP.",
"enabled": true
}
@@ -4,6 +4,6 @@
"category": "always",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name",
"action": "setCurrentApp",
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app.",
"enabled": true
"description": "Detects when IME begins processing an app with its dependency chain, setting it as the current app. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "always",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?\\bid\\W+{GUID}",
"action": "setCurrentApp",
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current.",
"enabled": true
"description": "Detects when the IME ExecManager starts processing a targeted app, setting it as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "always",
"pattern": "\\[Win32App\\] ===Step=== Start to Present app {GUID}",
"action": "setCurrentApp",
"description": "Detects the start of app presentation step in IME, setting the app as current.",
"enabled": true
"description": "Detects the start of app presentation step in IME, setting the app as current. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] (?=.*?\\bapplicability\\b)(?=.*?NotMet\\b).*?\\bapp\\W+{GUID}\\b",
"action": "updateStateSkipped",
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads).",
"enabled": true
"description": "Detects when an app's applicability check returns NotMet (old log format with lookaheads). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] app\\W+{GUID}\\b.*?\\bis a dependency\\b.*?\\bDetect Only mode\\b.*?\\bfinished processing",
"action": "updateStateSkipped",
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed).",
"enabled": true
"description": "Detects when a dependency app in Detect Only mode finishes processing (no install needed). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] Detected app {GUID}.*?skip next step",
"action": "updateStateSkipped",
"description": "Detects when an already-detected app is skipped for further processing steps (old log format).",
"enabled": true
"description": "Detects when an already-detected app is skipped for further processing steps (old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] NOT detected app {GUID}.*?skip un-installation",
"action": "updateStateSkipped",
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format).",
"enabled": true
"description": "Detects when an app is not detected and uninstall is skipped (alternate old log format). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -0,0 +1,10 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-STOP-SIGNAL",
"category": "currentPhase",
"pattern": "EMS Agent received stop signal",
"action": "updateStatePostponed",
"parameters": { "useCurrentApp": "true" },
"description": "Detects an IME service stop ('EMS Agent received stop signal', EmsAgentService.OnStop) — fired on manual restarts and agent-updater restarts, unlike the OS-shutdown-only 'shutdown signal' line matched by IME-SHUTDOWN. Postpones the current app installation, mirroring IME-SHUTDOWN semantics.",
"enabled": true
}
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-TOKEN-FAILURE",
"category": "always",
"pattern": "Failed to get AAD token\\..*?errorCode = (?<errorCode>.*?)$",
"action": "imeTokenFailure",
"description": "IME token acquisition failure ('Failed to get AAD token. len = N using client id ... and resource id ..., errorCode = N', IntuneTokenManager, IntuneManagementExtension.log). Complements IME-TOKEN-SUCCESS: an auth outage (WAM broken, conditional access, network) previously left every check-in silently doing nothing with no timeline evidence. errorCode can be empty when IME logged none; the agent dedups to one Warning per distinct code. Verified against IME 1.97 and 1.104 decompiled sources.",
"enabled": true
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] ExecManager: processing targeted app.*?name\\W+(?<name>.*?)\\W+id\\W+{GUID}",
"action": "updateName",
"description": "Captures the app name from ExecManager processing log (old format: name before id).",
"enabled": true
"description": "Captures the app name from ExecManager processing log (old format: name before id). DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] ProcessAppWithDependencies starts for {GUID} with name (?<name>.*?)$",
"action": "updateName",
"description": "Captures the app name from ProcessAppWithDependencies log entry.",
"enabled": true
"description": "Captures the app name from ProcessAppWithDependencies log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -4,6 +4,6 @@
"category": "currentPhase",
"pattern": "\\[Win32App\\] Processing app \\(id={GUID}, name = (?<name>.*?)\\)",
"action": "updateName",
"description": "Captures the app name from the Processing app log entry.",
"enabled": true
"description": "Captures the app name from the Processing app log entry. DISABLED 2026-08-17: emitter no longer exists in current IME (1.97.107, Dec 2025) - the old V1/V2 app processing pipeline was removed and this line is not logged anymore (verified against decompiled source, see tasks/ime-v1-code-audit-2026-08-17.md). Kept for history.",
"enabled": false
}
@@ -0,0 +1,9 @@
{
"$schema": "../schema/ime-log-pattern.schema.json",
"patternId": "IME-WINGET-ERROR",
"category": "currentPhase",
"pattern": "\\[WinGetMessageProcessor\\] Processing Progress for app = {GUID}.*?- Operation Phase = Error",
"action": "updateStateError",
"description": "Marks a WinGet (new Store) app as failed when its operation progress reports phase Error (WinGetOperationInstallPhase: Downloading/Installing/Queued/Finished/Error). Before this pattern only the Installing phase was matched, so WinGet failures never surfaced as app errors (IME 1.97 audit 2026-08-17).",
"enabled": true
}
+1
View File
@@ -61,6 +61,7 @@
"cancelStuckAndSetCurrent",
"imeSessionChange",
"imeImpersonation",
"imeTokenFailure",
"enrollmentCompleted",
"updateDoTelemetry",
"scriptStarted",
@@ -36,6 +36,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Harness
{
EspSkipConfigurationProbe.TestOverride = _ => (null, null);
EspSkipConfigurationProbe.FullTestOverride = _ => EspFirstSyncSnapshot.Empty;
// Same reasoning for the IME-install-source probe: a dev machine with real
// EnterpriseDesktopAppManagement CSP state would nondeterministically enrich
// ime_agent_version payloads in adapter tests.
ImeMsiInstallSourceProbe.TestOverride = (_, _) => ImeMsiInstallSource.Empty;
}
}
}
@@ -46,7 +46,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Directory.CreateDirectory(SpoolFolder);
}
public DiagnosticsPackageService Build()
public DiagnosticsPackageService Build(System.Action<AgentConfiguration>? mutateConfig = null)
{
// BackendApiClient is required by the public ctor but BuildArchiveBytes
// never touches it — construct with a throwaway HttpClient. Tests only
@@ -61,8 +61,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
bootstrapToken: null,
agentVersion: "0.0.0",
logger: Logger);
var cfg = Cfg();
mutateConfig?.Invoke(cfg);
return new DiagnosticsPackageService(
Cfg(),
cfg,
Logger,
apiClient,
agentLogFolderOverride: LogsTmp.Path,
@@ -131,6 +133,72 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Runtime
Assert.Contains("AgentMarkers/agent_clean_exit.marker", entries);
}
// ============================================================ package manifest ====
// The agent-log snapshot is zipped BEFORE packaging finishes, so packaging problems
// are invisible in the uploaded archive's own log (field case: missing evtx in
// sessions a11102f4/3ae7528b). The manifest travels INSIDE the ZIP instead.
private static string? ReadZipEntryText(byte[] bytes, string entryName)
{
using var ms = new MemoryStream(bytes);
using var archive = new System.IO.Compression.ZipArchive(ms, System.IO.Compression.ZipArchiveMode.Read);
var entry = archive.GetEntry(entryName);
if (entry == null) return null;
using var reader = new StreamReader(entry.Open());
return reader.ReadToEnd();
}
[Fact]
public void BuildArchiveBytes_always_writes_package_manifest_with_added_entries()
{
using var rig = new Rig();
File.WriteAllText(Path.Combine(rig.StateFolder, "snapshot.json"), "{}");
var bytes = rig.Build().BuildArchiveBytes(enrollmentSucceeded: true);
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
Assert.NotNull(manifest);
Assert.Contains("ADDED: AgentState/snapshot.json", manifest);
// Empty standard folders (e.g. ImeLogs) surface as NO MATCH lines — the silent
// nothing-found case is exactly what the manifest exists to make visible.
Assert.Contains("NO MATCH:", manifest);
}
[Fact]
public void BuildArchiveBytes_manifest_records_guard_blocked_additional_path()
{
using var rig = new Rig();
var blockedPath = @"C:\Windows\System32\config\SYSTEM";
var bytes = rig.Build(cfg => cfg.DiagnosticsLogPaths.Add(
new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = blockedPath }))
.BuildArchiveBytes(enrollmentSucceeded: true);
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
Assert.NotNull(manifest);
Assert.Contains($"BLOCKED (path guard): {blockedPath}", manifest);
}
[Fact]
public void BuildArchiveBytes_manifest_records_users_path_blocked_even_in_unrestricted()
{
using var rig = new Rig();
// The test temp dir lives under C:\Users — blocked by the always-on privacy guard
// even in unrestricted mode. The manifest must say so instead of staying silent.
var configuredPath = Path.Combine(rig.DataFolder, "does-not-exist.evtx");
var bytes = rig.Build(cfg =>
{
cfg.UnrestrictedMode = true;
cfg.DiagnosticsLogPaths.Add(new AutopilotMonitor.Shared.Models.DiagnosticsLogPath { Path = configuredPath });
})
.BuildArchiveBytes(enrollmentSucceeded: true);
var manifest = ReadZipEntryText(bytes, "package-manifest.txt");
Assert.NotNull(manifest);
Assert.Contains($"BLOCKED (path guard): {configuredPath}", manifest);
}
[Fact]
public void BuildArchiveBytes_excludes_top_level_session_id_and_bootstrap_config()
{
@@ -0,0 +1,183 @@
#nullable enable
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.SystemSignals
{
/// <summary>
/// Selection/normalization logic of <see cref="ImeMsiInstallSourceProbe"/> — the registry
/// walk itself is not unit-testable (live HKLM), so tests cover the pure pieces:
/// <c>SelectBestMatch</c>, <c>VersionsEqual</c>, <c>IsImeMsiUrl</c>.
/// </summary>
public class ImeMsiInstallSourceProbeTests
{
private const string ImeProductCode = "{6A7DFC50-0395-4E1E-BF84-ED1404E72051}";
private const string ImeUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
private static ImeMsiInstallSourceProbe.MsiEntry Entry(
string productCode = ImeProductCode,
string version = "1.104.102.0",
string url = ImeUrl)
=> new ImeMsiInstallSourceProbe.MsiEntry(productCode, version, url);
// -- SelectBestMatch -----------------------------------------------------------
[Fact]
public void SelectBestMatch_exact_product_version_wins()
{
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry>
{
Entry(productCode: "{OTHER}", version: "9.9.9.9", url: "https://x/Other.msi"),
Entry(),
},
"1.104.102.0");
Assert.True(result.HasData);
Assert.True(result.MatchedByVersion);
Assert.Equal(ImeProductCode, result.ProductCode);
Assert.Equal("1.104.102.0", result.ProductVersion);
Assert.Equal(ImeUrl, result.DownloadUrl);
}
[Fact]
public void SelectBestMatch_normalized_version_matches_trailing_zero()
{
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry(version: "1.104.102") },
"1.104.102.0");
Assert.True(result.HasData);
Assert.True(result.MatchedByVersion);
}
[Fact]
public void SelectBestMatch_version_mismatch_falls_back_to_ime_msi_filename()
{
// IME self-updated between MSI enforcement and log line — versions drift, but the
// enforced IntuneWindowsAgent.msi entry is still the install source.
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry>
{
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
Entry(version: "1.103.100.0"),
},
"1.104.102.0");
Assert.True(result.HasData);
Assert.False(result.MatchedByVersion);
Assert.Equal("1.103.100.0", result.ProductVersion);
Assert.Equal(ImeUrl, result.DownloadUrl);
}
[Fact]
public void SelectBestMatch_version_match_beats_filename_match_on_other_entry()
{
// A stale IntuneWindowsAgent.msi row must not shadow the entry whose
// ProductVersion equals the running build.
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry>
{
Entry(productCode: "{STALE}", version: "1.103.100.0"),
Entry(productCode: "{CURRENT}", version: "1.104.102.0", url: "https://y/IntuneWindowsAgent.msi"),
},
"1.104.102.0");
Assert.True(result.MatchedByVersion);
Assert.Equal("{CURRENT}", result.ProductCode);
}
[Fact]
public void SelectBestMatch_no_plausible_entry_returns_empty()
{
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry>
{
Entry(productCode: "{OTHER}", version: "5.0.0.0", url: "https://x/SomeLobApp.msi"),
},
"1.104.102.0");
Assert.False(result.HasData);
}
[Fact]
public void SelectBestMatch_empty_or_null_candidates_return_empty()
{
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry>(), "1.104.102.0").HasData);
Assert.False(ImeMsiInstallSourceProbe.SelectBestMatch(
null, "1.104.102.0").HasData);
}
[Fact]
public void SelectBestMatch_null_registry_values_do_not_throw()
{
var result = ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry>
{
new ImeMsiInstallSourceProbe.MsiEntry("{PARTIAL}", null, null),
Entry(),
},
"1.104.102.0");
Assert.True(result.HasData);
Assert.Equal(ImeProductCode, result.ProductCode);
}
// -- VersionsEqual ---------------------------------------------------------------
[Theory]
[InlineData("1.104.102.0", "1.104.102.0", true)]
[InlineData("1.104.102", "1.104.102.0", true)]
[InlineData("1.104.102.0", "1.104.102", true)]
[InlineData(" 1.104.102.0 ", "1.104.102.0", true)]
[InlineData("1.104.102.0", "1.104.103.0", false)]
[InlineData("1.104.102.0", "1.104.102.1", false)]
[InlineData("1.104", "1.104.102.0", false)]
[InlineData("abc", "abc", true)] // non-numeric: whole-string equality
[InlineData("abc", "abd", false)] // non-numeric components never fuzzy-match
[InlineData(null, "1.0", false)]
[InlineData("1.0", null, false)]
[InlineData("", "", false)]
public void VersionsEqual_cases(string? a, string? b, bool expected)
{
Assert.Equal(expected, ImeMsiInstallSourceProbe.VersionsEqual(a, b));
}
// -- IsImeMsiUrl -------------------------------------------------------------------
[Theory]
[InlineData(ImeUrl, true)]
[InlineData("https://x/intunewindowsagent.MSI", true)] // case-insensitive
[InlineData("https://x/IntuneWindowsAgent.msi?sv=abc&sig=def", true)] // query ignored
[InlineData("IntuneWindowsAgent.msi", true)] // bare filename
[InlineData("https://x/NotTheAgent.msi", false)]
[InlineData("https://x/IntuneWindowsAgent.msi.bak", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsImeMsiUrl_cases(string? url, bool expected)
{
Assert.Equal(expected, ImeMsiInstallSourceProbe.IsImeMsiUrl(url));
}
// -- ScopedOverride ---------------------------------------------------------------
[Fact]
public void ScopedOverride_restores_previous_override_on_dispose()
{
// Assembly default (TestAssemblyInit) forces Empty; a nested scope must restore it.
Func<string, AgentLogger, ImeMsiInstallSource> before =
ImeMsiInstallSourceProbe.TestOverride;
using (new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
ImeMsiInstallSourceProbe.SelectBestMatch(
new List<ImeMsiInstallSourceProbe.MsiEntry> { Entry() }, "1.104.102.0")))
{
Assert.True(ImeMsiInstallSourceProbe.Read("1.104.102.0").HasData);
}
Assert.Same(before, ImeMsiInstallSourceProbe.TestOverride);
}
}
}
@@ -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
{
/// <summary>
/// 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.
/// </summary>
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<AppPackageState> { 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<AppPackageState> { 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<AppPackageState>? 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<FakeSignalIngressSink.PostedSignal> Events(FakeSignalIngressSink sink, string eventType) =>
sink.Posted.Where(p =>
p.Payload != null
&& p.Payload.TryGetValue(SignalPayloadKeys.EventType, out var et)
&& et == eventType).ToList();
}
}
@@ -131,4 +131,117 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
trySchtasks: null!));
}
}
/// <summary>
/// Pins the restricted-token launch ordering introduced 2026-08-17: MDM LOB MSI installs
/// (EnterpriseDesktopAppManagement CSP → msiexec chain) carry a SYSTEM token with
/// SeTimeZonePrivilege / SeSystemEnvironmentPrivilege REMOVED, and WMI Win32_Process.Create
/// duplicates the caller token onto the runtime — so on a stripped token the Scheduled Task
/// (fresh full SYSTEM token) must launch first and WMI is only the degraded fallback. These
/// tests fail loudly if a refactor silently restores WMI-first for the stripped-token case
/// or drops the process verification after schtasks /Run.
/// </summary>
public sealed class InstallModeRestrictedTokenLaunchTests
{
[Fact]
public void Schtasks_success_with_verified_pid_returns_schtasks_and_never_touches_wmi()
{
var wmiInvocations = 0;
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => 0,
verifyRuntimePid: () => 4321,
tryWmi: () => { wmiInvocations++; return new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 9999); });
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Schtasks, result.Method);
Assert.Equal(4321, result.Pid);
Assert.Equal(0, result.SchtasksExitCode);
Assert.Equal(0, wmiInvocations);
Assert.Contains("full SYSTEM token", result.Diagnostic);
Assert.Contains("PID=4321", result.Diagnostic);
}
[Fact]
public void Schtasks_queued_but_no_process_appears_falls_back_to_wmi()
{
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => 0,
verifyRuntimePid: () => 0,
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 777));
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
Assert.Equal(777, result.Pid);
Assert.Contains("no runtime process appeared", result.Diagnostic);
}
[Fact]
public void Schtasks_failure_skips_verification_and_falls_back_to_wmi()
{
var verifyInvocations = 0;
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => 1,
verifyRuntimePid: () => { verifyInvocations++; return 0; },
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Wmi, result.Method);
Assert.Equal(0, verifyInvocations);
Assert.Equal(1, result.SchtasksExitCode);
}
[Fact]
public void Wmi_fallback_diagnostic_warns_that_the_runtime_stays_degraded()
{
// Support line: a restricted-token WMI launch means timezone auto-set / firmware
// reads will fail for this runtime instance — the log must say so explicitly.
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => 1,
verifyRuntimePid: () => 0,
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 555));
Assert.Contains("RESTRICTED TOKEN", result.Diagnostic);
Assert.Contains("timezone", result.Diagnostic, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Both_paths_fail_returns_deferred_with_both_codes_captured()
{
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => 1,
verifyRuntimePid: () => 0,
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(2u, 0));
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
Assert.Equal(0, result.Pid);
Assert.Equal(2u, result.WmiReturnValue);
Assert.Equal(1, result.SchtasksExitCode);
Assert.Contains("BootTrigger", result.Diagnostic);
}
[Fact]
public void Wmi_returnvalue_zero_but_pid_zero_does_not_count_as_success()
{
var result = AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => 1,
verifyRuntimePid: () => 0,
tryWmi: () => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 0));
Assert.Equal(AutopilotMonitor.Agent.V2.Program.RuntimeLaunchMethod.Deferred, result.Method);
}
[Fact]
public void Null_delegates_are_rejected()
{
Func<int> zero = () => 0;
Func<AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt> wmi =
() => new AutopilotMonitor.Agent.V2.Program.WmiLaunchAttempt(0u, 1);
Assert.Throws<ArgumentNullException>(
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(null!, zero, wmi));
Assert.Throws<ArgumentNullException>(
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, null!, wmi));
Assert.Throws<ArgumentNullException>(
() => AutopilotMonitor.Agent.V2.Program.DecideRestrictedTokenLaunchOutcome(zero, zero, null!));
}
}
}
@@ -0,0 +1,27 @@
using AutopilotMonitor.Agent.V2;
using Xunit;
namespace AutopilotMonitor.Agent.V2.Core.Tests.Program
{
/// <summary>
/// Environment-stable pins for the token probe. Whether SeTimeZonePrivilege itself is
/// held depends on the test runner's token, so it is NOT asserted here — only the two
/// contracts that never vary: a privilege present in every Windows token reports held,
/// and an unknown privilege name fails soft to "held" (the probe must never flip the
/// launch ordering because of its own defect).
/// </summary>
public sealed class TokenPrivilegeProbeTests
{
[Fact]
public void SeChangeNotifyPrivilege_is_held_in_every_token()
{
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeChangeNotifyPrivilege"));
}
[Fact]
public void Unknown_privilege_name_fails_soft_to_held()
{
Assert.True(TokenPrivilegeProbe.IsPrivilegeHeld("SeDefinitelyNotARealPrivilege"));
}
}
}
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq;
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.SignalAdapters;
using AutopilotMonitor.Agent.V2.Core.Tests.Harness;
using AutopilotMonitor.Agent.V2.Core.Tests.Orchestration;
@@ -461,6 +462,48 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal("IME Agent version: 1.101.109.0", info.Payload[SignalPayloadKeys.Message]);
}
[Fact]
public void ImeTokenFailure_emits_warning_event_with_error_code()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerImeTokenFailureFromTest("3399614467",
"Failed to get AAD token. len = 0 using client id x and resource id y, errorCode = 3399614467");
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
Assert.Equal("ImeLogTracker", info.Payload![SignalPayloadKeys.Source]);
Assert.Equal("3399614467", info.Payload["errorCode"]);
Assert.Contains("errorCode=3399614467", info.Payload[SignalPayloadKeys.Message]);
Assert.Contains("Failed to get AAD token", info.Payload["logLine"]);
}
[Fact]
public void ImeTokenFailure_tracker_dedups_per_error_code()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
f.Tracker.HandleImeTokenFailureForTest("100", "line a");
f.Tracker.HandleImeTokenFailureForTest("100", "line b"); // same code — suppressed
f.Tracker.HandleImeTokenFailureForTest("200", "line c"); // new code — emitted
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeTokenFailure).Count);
}
[Fact]
public void ImeTokenFailure_empty_error_code_reports_unknown()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerImeTokenFailureFromTest("", null!);
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeTokenFailure));
Assert.Equal("unknown", info.Payload!["errorCode"]);
Assert.False(info.Payload.ContainsKey("logLine"));
}
[Fact]
public void ImeAgentVersion_null_or_empty_is_skipped()
{
@@ -487,6 +530,70 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.SignalAdapters
Assert.Equal(2, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
}
[Fact]
public void ImeAgentVersion_enriches_with_csp_install_source_when_probe_matches()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((v, _) =>
ImeMsiInstallSourceProbe.SelectBestMatch(
new[]
{
new ImeMsiInstallSourceProbe.MsiEntry(
"{6A7DFC50-0395-4E1E-BF84-ED1404E72051}",
"1.104.102.0",
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi"),
},
v));
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
Assert.Equal("1.104.102.0", info.Payload!["agentVersion"]);
Assert.Equal(
"https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
info.Payload["msiDownloadUrl"]);
Assert.Equal("{6A7DFC50-0395-4E1E-BF84-ED1404E72051}", info.Payload["msiProductCode"]);
Assert.Equal("1.104.102.0", info.Payload["msiProductVersion"]);
Assert.Equal("productVersion", info.Payload["msiMatchedBy"]);
}
[Fact]
public void ImeAgentVersion_without_csp_match_omits_msi_keys()
{
// Assembly default (TestAssemblyInit) forces the probe to Empty.
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
var info = Assert.Single(f.InfoEvents(SharedEventTypes.ImeAgentVersion));
Assert.False(info.Payload!.ContainsKey("msiDownloadUrl"));
Assert.False(info.Payload.ContainsKey("msiProductCode"));
Assert.False(info.Payload.ContainsKey("msiProductVersion"));
Assert.False(info.Payload.ContainsKey("msiMatchedBy"));
}
[Fact]
public void ImeAgentVersion_probe_result_is_cached_per_version()
{
using var f = new ImeLogTrackerAdapterFixture();
using var adapter = new ImeLogTrackerAdapter(f.Tracker, f.Ingress, f.Clock);
int probeCalls = 0;
using var _probe = new ImeMsiInstallSourceProbe.ScopedOverride((_, _) =>
{
probeCalls++;
return ImeMsiInstallSource.Empty;
});
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
adapter.TriggerImeAgentVersionFromTest("1.104.102.0");
adapter.TriggerImeAgentVersionFromTest("1.105.0.0");
Assert.Equal(2, probeCalls); // one read per distinct version, not per event
Assert.Equal(3, f.InfoEvents(SharedEventTypes.ImeAgentVersion).Count);
}
[Fact]
public void DoTelemetry_emits_do_telemetry_info_event_with_do_fields()
{
@@ -25,6 +25,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
private DateTime _impersonationLastRollupUtc = DateTime.MinValue;
private static readonly TimeSpan ImpersonationRollupInterval = TimeSpan.FromSeconds(60);
// IME retries token acquisition on every check-in — dedup per distinct error
// code so an outage yields one Warning, not a timeline flood.
private readonly HashSet<string> _reportedTokenFailureCodes = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private void HandleImeTokenFailure(string errorCode, string message)
{
var code = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode.Trim();
if (!_reportedTokenFailureCodes.Add(code)) return;
var excerpt = message ?? string.Empty;
if (excerpt.Length > 300) excerpt = excerpt.Substring(0, 300);
_logger.Warning($"ImeLogTracker: IME token acquisition failure (errorCode={code})");
OnImeTokenFailure?.Invoke(code, excerpt);
}
// Test seam: drives HandleImeTokenFailure without the regex pipeline.
internal void HandleImeTokenFailureForTest(string errorCode, string message)
=> HandleImeTokenFailure(errorCode, message);
private void HandleImeImpersonation(string user)
{
var current = string.IsNullOrEmpty(user) ? "(unknown)" : user;
@@ -214,6 +214,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
"capturedetectionresult", "esptrackstatus", "policiesdiscovered",
"ignorecompletedapp", "updatename", "updatewin32appstate",
"cancelstuckandsetcurrent", "updatedotelemetry",
// Not app-mutating, but a stale token-failure replayed from a previous
// enrollment's log would emit a misleading Warning for THIS session.
"imetokenfailure",
};
private void HandlePatternMatch(CompiledPattern pattern, Match match, string message, CmTraceLogEntry entry)
@@ -296,6 +299,10 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
OnImeAgentVersion?.Invoke(version);
break;
case "imetokenfailure":
HandleImeTokenFailure(match.Groups["errorCode"]?.Value, message);
break;
case "imeimpersonation":
// PR3-A2: dedup. The same user triggers ~24 identical lines per session;
// log on first/changed user, otherwise count and emit a single rollup
@@ -119,6 +119,12 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
"IntuneManagementExtension-????????-??????.log",
"AppWorkload.log",
"AppWorkload-????????-??????.log",
// Enforcement decision engine (V3 ActionProcessor). IME routes every
// [Win32App][ActionProcessor] line here (app.config TraceSource mapping) —
// NO-ACTION / NOT-TARGETED classifications and the GRS "will not be
// enforced" skips are ONLY visible in this file (audit 2026-08-17).
"AppActionProcessor.log",
"AppActionProcessor-????????-??????.log",
// PowerShell script tracking
"AgentExecutor.log",
"AgentExecutor-????????-??????.log",
@@ -194,6 +200,9 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.Ime
public Action OnAllAppsCompleted { get; set; }
public Action OnUserSessionCompleted { get; set; }
public Action<string> OnImeSessionChange { get; set; }
// Args: errorCode (may be empty when IME logged none), raw failure line excerpt.
// Fired once per distinct errorCode per tracker lifetime (see HandleImeTokenFailure).
public Action<string, string> OnImeTokenFailure { get; set; }
// Second arg: the [DO TEL] source log line's timestamp when the DO data came from a
// log line, null when the DO collector observed the completion live via polling. The
// consumer must stamp poll-driven telemetry with the clock — inheriting an unrelated
@@ -0,0 +1,235 @@
using System;
using System.Collections.Generic;
using AutopilotMonitor.Agent.V2.Core.Logging;
using Microsoft.Win32;
namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Enrollment.SystemSignals
{
/// <summary>
/// Session 2026-08-17 — resolves where the running IME build was downloaded from.
/// The IME is installed through the EnterpriseDesktopAppManagement CSP, which documents
/// every MSI it enforces under
/// <c>HKLM\SOFTWARE\Microsoft\EnterpriseDesktopAppManagement\&lt;SID&gt;\MSI\{ProductCode}</c>
/// (values: <c>CurrentDownloadUrl</c>, <c>ProductVersion</c>, <c>ProductCode</c>, …).
/// Given the IME version parsed from the IME log, this probe finds the matching MSI entry
/// and returns its <c>CurrentDownloadUrl</c> so the <c>ime_agent_version</c> event can say
/// which CDN/AFD endpoint delivered exactly that build (e.g.
/// <c>imeswdb-afd-secondary.manage.microsoft.com</c>).
/// <para>
/// Matching: first by normalized <c>ProductVersion</c> equality (trailing <c>.0</c>
/// components ignored), then — because the version line in the log and the enforced MSI can
/// drift apart around an IME self-update — by download-URL filename
/// <c>IntuneWindowsAgent.msi</c>. The match kind is surfaced so consumers know whether the
/// URL is version-authoritative.
/// </para>
/// <para>
/// Fail-soft like <see cref="EspTrackingInfoProbe"/>: any failure is Debug-logged and yields
/// <see cref="ImeMsiInstallSource.Empty"/>; a missing CSP key (no MDM-enforced MSIs yet) is
/// not an error.
/// </para>
/// </summary>
internal static class ImeMsiInstallSourceProbe
{
internal const string CspRootKeyPath = @"SOFTWARE\Microsoft\EnterpriseDesktopAppManagement";
private const string ImeMsiFileName = "IntuneWindowsAgent.msi";
/// <summary>
/// Test seam — when non-null, <see cref="Read"/> delegates to this func instead of
/// touching the live registry. Use <see cref="ScopedOverride"/> to guarantee cleanup.
/// </summary>
internal static Func<string, AgentLogger, ImeMsiInstallSource> TestOverride;
/// <summary>
/// Finds the CSP MSI entry for <paramref name="imeVersion"/> (the version string parsed
/// from the IME log). Returns <see cref="ImeMsiInstallSource.Empty"/> when the CSP key is
/// missing, unreadable, or holds no plausible entry.
/// </summary>
/// <param name="imeVersion">IME version from the log, e.g. <c>1.104.102.0</c>.</param>
/// <param name="logger">Optional logger — Debug-level trace only; no warn/error.</param>
public static ImeMsiInstallSource Read(string imeVersion, AgentLogger logger = null)
{
var probe = TestOverride;
if (probe != null) return probe(imeVersion, logger);
try
{
var candidates = new List<MsiEntry>();
using (var rootKey = Registry.LocalMachine.OpenSubKey(CspRootKeyPath))
{
if (rootKey == null)
{
logger?.Debug("ImeMsiInstallSourceProbe: EnterpriseDesktopAppManagement registry key not found");
return ImeMsiInstallSource.Empty;
}
// Layout: <SID>\MSI\{ProductCode}. The IME sits under the device-scope
// zeros-SID, but enumerate all SIDs — layout is the same for user scope.
foreach (var sid in rootKey.GetSubKeyNames())
{
using (var msiKey = rootKey.OpenSubKey(sid + @"\MSI"))
{
if (msiKey == null) continue;
foreach (var productCode in msiKey.GetSubKeyNames())
{
using (var productKey = msiKey.OpenSubKey(productCode))
{
if (productKey == null) continue;
candidates.Add(new MsiEntry(
productCode: productCode,
productVersion: productKey.GetValue("ProductVersion") as string,
downloadUrl: productKey.GetValue("CurrentDownloadUrl") as string));
}
}
}
}
}
return SelectBestMatch(candidates, imeVersion, logger);
}
catch (Exception ex)
{
logger?.Debug($"ImeMsiInstallSourceProbe: read threw: {ex.Message}");
return ImeMsiInstallSource.Empty;
}
}
/// <summary>
/// Pure selection logic (internal for unit tests): exact/normalized ProductVersion match
/// wins; otherwise the first entry whose download URL points at
/// <c>IntuneWindowsAgent.msi</c>.
/// </summary>
internal static ImeMsiInstallSource SelectBestMatch(
IReadOnlyList<MsiEntry> candidates, string imeVersion, AgentLogger logger = null)
{
if (candidates == null || candidates.Count == 0) return ImeMsiInstallSource.Empty;
foreach (var entry in candidates)
{
if (VersionsEqual(entry.ProductVersion, imeVersion))
{
logger?.Debug($"ImeMsiInstallSourceProbe: matched by ProductVersion {entry.ProductVersion} ({entry.ProductCode})");
return new ImeMsiInstallSource(entry, matchedByVersion: true);
}
}
foreach (var entry in candidates)
{
if (IsImeMsiUrl(entry.DownloadUrl))
{
logger?.Debug($"ImeMsiInstallSourceProbe: matched by URL filename, registry version {entry.ProductVersion ?? "?"} vs log {imeVersion ?? "?"} ({entry.ProductCode})");
return new ImeMsiInstallSource(entry, matchedByVersion: false);
}
}
logger?.Debug($"ImeMsiInstallSourceProbe: no CSP MSI entry matches IME version {imeVersion ?? "?"} ({candidates.Count} candidates)");
return ImeMsiInstallSource.Empty;
}
/// <summary>
/// Component-wise numeric compare with trailing-zero tolerance, so
/// <c>1.104.102</c> == <c>1.104.102.0</c>. Non-numeric components fall back to an
/// ordinal-ignore-case string compare of the whole version.
/// </summary>
internal static bool VersionsEqual(string a, string b)
{
if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return false;
if (string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase)) return true;
var pa = a.Trim().Split('.');
var pb = b.Trim().Split('.');
var len = Math.Max(pa.Length, pb.Length);
for (var i = 0; i < len; i++)
{
var sa = i < pa.Length ? pa[i] : "0";
var sb = i < pb.Length ? pb[i] : "0";
if (!int.TryParse(sa, out var na) || !int.TryParse(sb, out var nb)) return false;
if (na != nb) return false;
}
return true;
}
/// <summary>URL filename equals <c>IntuneWindowsAgent.msi</c> (query string ignored).</summary>
internal static bool IsImeMsiUrl(string url)
{
if (string.IsNullOrEmpty(url)) return false;
var path = url;
var query = path.IndexOf('?');
if (query >= 0) path = path.Substring(0, query);
var slash = path.LastIndexOf('/');
var fileName = slash >= 0 ? path.Substring(slash + 1) : path;
return string.Equals(fileName, ImeMsiFileName, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Raw candidate row from one <c>MSI\{ProductCode}</c> subkey.</summary>
internal readonly struct MsiEntry
{
public MsiEntry(string productCode, string productVersion, string downloadUrl)
{
ProductCode = productCode;
ProductVersion = productVersion;
DownloadUrl = downloadUrl;
}
public string ProductCode { get; }
public string ProductVersion { get; }
public string DownloadUrl { get; }
}
/// <summary>
/// Disposable scope that sets <see cref="TestOverride"/> for the lifetime of the scope
/// and restores the previous value on Dispose. Nestable. Internal test-only helper.
/// </summary>
internal sealed class ScopedOverride : IDisposable
{
private readonly Func<string, AgentLogger, ImeMsiInstallSource> _previous;
private int _disposed;
public ScopedOverride(Func<string, AgentLogger, ImeMsiInstallSource> probe)
{
_previous = TestOverride;
TestOverride = probe;
}
public void Dispose()
{
if (System.Threading.Interlocked.Exchange(ref _disposed, 1) == 1) return;
TestOverride = _previous;
}
}
}
/// <summary>
/// Resolved install source of the running IME build. <see cref="HasData"/> is <c>false</c>
/// when no CSP MSI entry matched. <see cref="MatchedByVersion"/> distinguishes a
/// ProductVersion match (URL is authoritative for the reported build) from the
/// filename fallback (URL is the currently enforced IME MSI, version may have drifted).
/// </summary>
internal readonly struct ImeMsiInstallSource
{
public static readonly ImeMsiInstallSource Empty = default;
internal ImeMsiInstallSource(ImeMsiInstallSourceProbe.MsiEntry entry, bool matchedByVersion)
{
HasData = true;
ProductCode = entry.ProductCode;
ProductVersion = entry.ProductVersion;
DownloadUrl = entry.DownloadUrl;
MatchedByVersion = matchedByVersion;
}
public bool HasData { get; }
/// <summary>Braced MSI ProductCode subkey name, e.g. <c>{6a7dfc50-0395-4e1e-bf84-ed1404e72051}</c>.</summary>
public string ProductCode { get; }
/// <summary><c>ProductVersion</c> registry value (may be null on partial writes).</summary>
public string ProductVersion { get; }
/// <summary><c>CurrentDownloadUrl</c> registry value (may be null on partial writes).</summary>
public string DownloadUrl { get; }
/// <summary><c>true</c> = matched via ProductVersion; <c>false</c> = URL-filename fallback.</summary>
public bool MatchedByVersion { get; }
}
}
@@ -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
{
/// <summary>
/// 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 <c>HKLM\SOFTWARE\Microsoft\IntuneManagementExtension</c>:
/// <list type="bullet">
/// <item><c>Win32Apps\&lt;userGuid&gt;\&lt;appGuid&gt;_&lt;rev&gt;</c> — EnforcementStateMessage JSON
/// (EnforcementState + ErrorCode), ExitCode, Intent. Device context = Guid.Empty user.</item>
/// <item><c>EspTrackingWin32Apps\&lt;userGuid&gt;\&lt;appGuid&gt;_&lt;rev&gt;</c> — which apps IME
/// registered for ESP tracking and in which phase.</item>
/// <item><c>SideCarPolicies\StatusServiceReports\&lt;userGuid&gt;\&lt;appId&gt;</c> — the exact
/// AppInstallStatusReport the ESP page renders (Status 1000=Installed .. 3000=Failed).</item>
/// </list>
/// 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 <c>registry_app_state</c> 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 <see cref="ReconcileSettleDelay"/>, its outcome
/// is compared against the log-derived <see cref="AppPackageState"/>. A divergence means the
/// IME log patterns missed or mis-classified the app — emitted once per app as
/// <c>app_state_reconciliation</c> (Warning). Pure observability: nothing here feeds the
/// DecisionEngine.
/// </summary>
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<IReadOnlyList<AppPackageState>>? _trackerStateProbe;
private readonly object _sync = new object();
private ImeRegistrySnapshot? _last;
private readonly Dictionary<string, DateTime> _terminalSinceUtc =
new Dictionary<string, DateTime>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _changedDuringSession =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reconciliationEmitted =
new HashSet<string>(StringComparer.OrdinalIgnoreCase);
private int _stateEventsEmitted;
private bool _capNoticeEmitted;
// Test seam: replaces the live registry walk (same convention as EspTrackingInfoProbe).
internal static Func<ImeRegistrySnapshot>? SnapshotOverride;
internal sealed class ScopedSnapshotOverride : IDisposable
{
private readonly Func<ImeRegistrySnapshot>? _previous;
private int _disposed;
public ScopedSnapshotOverride(Func<ImeRegistrySnapshot> 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<IReadOnlyList<AppPackageState>>? trackerStateProbe)
{
_post = post ?? throw new ArgumentNullException(nameof(post));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_logger = logger;
_trackerStateProbe = trackerStateProbe;
}
/// <summary>
/// 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.
/// </summary>
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 _);
/// <summary>
/// Win32Apps app keys are <c>&lt;appGuid&gt;_&lt;revision&gt;</c>; StatusServiceReports keys
/// are the bare GUID. Returns the lowercase app GUID or null for non-app keys (GRS, ...).
/// </summary>
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;
}
}
/// <summary>StateMessageEnforcementState bands (verified against decompiled IME 1.97/1.104).</summary>
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";
}
/// <summary>AppInstallStatus (StatusService/ESP page): 1000er = installed, 3000 = failed.</summary>
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<AppRegistryChange> DiffSnapshots(ImeRegistrySnapshot previous, ImeRegistrySnapshot next)
{
var changes = new List<AppRegistryChange>();
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<string> ChangedFields(AppRegistryEntry? prev, AppRegistryEntry next)
{
var changed = new List<string>();
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;
}
/// <summary>"success" / "error" when the registry outcome is terminal, else null.</summary>
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;
}
/// <summary>
/// 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.
/// </summary>
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<string, string>(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<AppPackageState>? 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<AppPackageState>()).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<string, string>(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})");
}
}
}
/// <summary>Immutable-ish snapshot of the three IME registry surfaces, keyed per user-context + app.</summary>
internal sealed class ImeRegistrySnapshot
{
public const string DeviceContext = "00000000-0000-0000-0000-000000000000";
public Dictionary<string, AppRegistryEntry> Entries { get; } =
new Dictionary<string, AppRegistryEntry>(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<string> changedFields, bool isNew)
{
Entry = entry;
ChangedFields = changedFields;
IsNew = isNew;
}
public AppRegistryEntry Entry { get; }
public List<string> ChangedFields { get; }
public bool IsNew { get; }
}
}
@@ -346,11 +346,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
}
}
// Chicken-and-egg guard: the agent-log snapshot is zipped BEFORE packaging finishes, so
// packaging problems (blocked paths, missing files, failed copies) are invisible in the
// uploaded archive's own log. This manifest records every packaging decision and travels
// INSIDE the ZIP (field case: sessions a11102f4 / 3ae7528b, missing evtx undiagnosable).
private StringBuilder _manifest;
private void ManifestLine(string text)
{
_manifest?.AppendLine($"[{DateTime.UtcNow:HH:mm:ss.fff}Z] {text}");
}
// Builds the diagnostics ZIP body in-memory. Extracted from CreateAndUploadAsync so
// tests can assert archive contents without going through the upload path.
internal virtual byte[] BuildArchiveBytes(bool? enrollmentSucceeded)
{
var tracker = new BudgetTracker(Budget);
_manifest = new StringBuilder();
using (var ms = new MemoryStream())
{
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
@@ -394,12 +406,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!DiagnosticsPathGuards.IsDiagnosticsPathAllowed(entry.Path, _configuration.UnrestrictedMode, userProfilePath))
{
_logger.Warning($"Diagnostics path blocked by guard: {entry.Path}");
ManifestLine($"BLOCKED (path guard): {entry.Path}");
continue;
}
var expandedPath = UserProfileResolver.ExpandCustomTokens(entry.Path);
if (expandedPath == null)
{
_logger.Warning($"Diagnostics path skipped (no user session for token): {entry.Path}");
ManifestLine($"SKIPPED (no user session for token): {entry.Path}");
continue;
}
var folder = Path.GetDirectoryName(expandedPath);
@@ -407,9 +421,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (string.IsNullOrEmpty(folder)) continue;
if (string.IsNullOrEmpty(pattern) || !pattern.Contains(".")) pattern = "*";
var zipFolder = $"AdditionalLogs/{Path.GetFileName(folder)}";
ManifestLine($"CONFIGURED PATH: '{entry.Path}' -> folder='{folder}' pattern='{pattern}'");
AddLogFiles(archive, folder, zipFolder, pattern, tracker, entry.IncludeSubfolders);
}
// Packaging manifest — always written, even when empty of problems, so its
// absence itself is meaningful (archive predates the feature).
try
{
var manifestEntry = archive.CreateEntry("package-manifest.txt", CompressionLevel.Optimal);
using (var writer = new StreamWriter(manifestEntry.Open()))
writer.Write(_manifest.ToString());
}
catch (Exception ex)
{
_logger.Warning($"Failed to write package manifest: {ex.Message}");
}
// Always last: emit truncation report only if any file was skipped.
if (tracker.HasSkips)
WriteTruncatedMarker(archive, tracker);
@@ -483,6 +511,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (!Directory.Exists(sourceFolder))
{
_logger.Debug($"Log folder not found, skipping: {sourceFolder}");
ManifestLine($"FOLDER MISSING: {sourceFolder} (pattern '{searchPattern}')");
return;
}
@@ -494,9 +523,13 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
catch (Exception ex)
{
_logger.Warning($"Failed to enumerate log files in {sourceFolder}: {ex.Message}");
ManifestLine($"ENUMERATION FAILED: {sourceFolder} (pattern '{searchPattern}'): {ex.Message}");
return;
}
if (files.Count == 0)
ManifestLine($"NO MATCH: {sourceFolder} pattern '{searchPattern}'");
foreach (var file in files)
{
try
@@ -514,6 +547,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
_logger.Warning($"Skipping reparse-point file: {file}");
tracker.RecordSkip(file, "reparse", 0);
ManifestLine($"SKIPPED (reparse point): {file}");
continue;
}
@@ -525,48 +559,184 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
continue;
}
if (length > tracker.Budget.MaxSingleFileBytes)
// Active event-log channels hold their .evtx exclusively locked — a raw
// FileStream open fails with a sharing violation. Export the channel via
// wevtutil instead and stream the export (deleted below). Session a11102f4:
// the tenant-configured BootstrapperAgent channel never made it into the ZIP.
string streamSource = file;
string tempExport = null;
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
{
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
tracker.RecordSkip(file, "size", length);
continue;
}
if (tracker.WouldExceedCount())
{
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
tracker.RecordSkip(file, "count", length);
continue;
}
if (tracker.WouldExceedTotal(length))
{
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
tracker.RecordSkip(file, "total", length);
continue;
tempExport = TryExportEventLogChannel(file);
if (tempExport != null)
{
streamSource = tempExport;
try { length = new FileInfo(tempExport).Length; }
catch { /* keep original length estimate */ }
}
else
{
// Export failed (channel not resolvable / wevtutil error) — fall through
// to the raw copy. Inactive or archived evtx files are not locked, so
// the plain FileStream often still succeeds; if the channel IS active
// and locked, the existing per-file catch logs the failure.
_logger.Warning($"Event log export unavailable for {file} — attempting raw copy.");
ManifestLine($"EVTX EXPORT FAILED (falling back to raw copy): {file}");
}
}
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
using (var fs = new FileStream(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var entryStream = entry.Open())
try
{
fs.CopyTo(entryStream);
}
if (length > tracker.Budget.MaxSingleFileBytes)
{
_logger.Warning($"Skipping oversized file ({length} bytes > {tracker.Budget.MaxSingleFileBytes} cap): {file}");
tracker.RecordSkip(file, "size", length);
ManifestLine($"SKIPPED (single-file cap, {length} bytes): {file}");
continue;
}
if (tracker.WouldExceedCount())
{
_logger.Warning($"Skipping file (file-count cap {tracker.Budget.MaxFileCount} reached): {file}");
tracker.RecordSkip(file, "count", length);
ManifestLine($"SKIPPED (file-count cap): {file}");
continue;
}
if (tracker.WouldExceedTotal(length))
{
_logger.Warning($"Skipping file (total-bytes cap {tracker.Budget.MaxTotalUncompressedBytes} reached): {file}");
tracker.RecordSkip(file, "total", length);
ManifestLine($"SKIPPED (total-bytes cap): {file}");
continue;
}
tracker.RecordIncluded(length);
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
// Preserve subfolder structure in the ZIP when includeSubfolders is enabled
var relativePath = file.Substring(sourceFolder.Length).TrimStart(Path.DirectorySeparatorChar);
var entryName = $"{zipFolder}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}";
var entry = archive.CreateEntry(entryName, CompressionLevel.Optimal);
// Stream directly from disk into the entry — no per-file MemoryStream/byte[]
// copy. FileShare.ReadWrite avoids locking conflicts with active log writers.
using (var fs = new FileStream(streamSource, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var entryStream = entry.Open())
{
fs.CopyTo(entryStream);
}
tracker.RecordIncluded(length);
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
ManifestLine($"ADDED: {entryName} ({length} bytes)");
}
finally
{
if (tempExport != null)
{
try { File.Delete(tempExport); } catch { }
}
}
}
catch (Exception ex)
{
_logger.Warning($"Failed to add log file to diagnostics package: {file} - {ex.Message}");
ManifestLine($"FAILED (copy): {file}: {ex.Message}");
}
}
}
/// <summary>
/// Resolves the event-log CHANNEL that writes to the given .evtx file by matching the
/// registered channels' LogFilePath — never by guessing a naming convention: most
/// channels follow the "%4 encodes '/'" file naming, but not all do (field case
/// 2026-08-17: Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider
/// writes plain "BootstrapperAgentServiceLogProvider.evtx"). Falls back to the %4
/// convention only when no registered channel claims the file.
/// </summary>
internal static string ResolveChannelForEvtxFile(string evtxPath)
{
try
{
var target = Path.GetFullPath(evtxPath);
using (var session = new System.Diagnostics.Eventing.Reader.EventLogSession())
{
foreach (var logName in session.GetLogNames())
{
try
{
var config = new System.Diagnostics.Eventing.Reader.EventLogConfiguration(logName, session);
var logFile = config.LogFilePath;
if (string.IsNullOrEmpty(logFile)) continue;
var expanded = Path.GetFullPath(Environment.ExpandEnvironmentVariables(logFile));
if (string.Equals(expanded, target, StringComparison.OrdinalIgnoreCase))
return logName;
}
catch { /* individual channel unreadable — keep scanning */ }
}
}
}
catch { /* enumeration unavailable — fall back to convention */ }
return Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
}
/// <summary>
/// Exports the event-log channel behind a winevt .evtx file to a temp copy via
/// <c>wevtutil epl</c> (active channels hold their file exclusively locked, so a raw
/// copy fails). Channel resolution happens against the registered channels — see
/// <see cref="ResolveChannelForEvtxFile"/>. Returns the temp file path (caller deletes)
/// or null when the export failed.
/// </summary>
private string TryExportEventLogChannel(string evtxPath)
{
string tempPath = null;
try
{
var channel = ResolveChannelForEvtxFile(evtxPath);
tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx");
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = Path.Combine(Environment.SystemDirectory, "wevtutil.exe"),
Arguments = $"epl \"{channel}\" \"{tempPath}\" /ow:true",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardError = true,
};
using (var process = System.Diagnostics.Process.Start(psi))
{
if (process == null) return null;
if (!process.WaitForExit(30000))
{
try { process.Kill(); } catch { }
_logger.Warning($"wevtutil export timed out for channel '{channel}'.");
ManifestLine($"EVTX EXPORT TIMEOUT: channel '{channel}' ({evtxPath})");
try { File.Delete(tempPath); } catch { }
return null;
}
if (process.ExitCode != 0)
{
var stderr = process.StandardError.ReadToEnd();
_logger.Warning($"wevtutil export failed for channel '{channel}' (exit {process.ExitCode}): {stderr}");
ManifestLine($"EVTX EXPORT ERROR: channel '{channel}' exit {process.ExitCode}: {stderr}");
try { File.Delete(tempPath); } catch { }
return null;
}
}
if (File.Exists(tempPath))
{
ManifestLine($"EVTX EXPORTED: channel '{channel}' -> {evtxPath}");
return tempPath;
}
return null;
}
catch (Exception ex)
{
_logger.Warning($"Event log export failed for {evtxPath}: {ex.Message}");
if (tempPath != null)
{
try { File.Delete(tempPath); } catch { }
}
return null;
}
}
private static void WriteTruncatedMarker(ZipArchive archive, BudgetTracker tracker)
{
var sb = new StringBuilder();
@@ -156,7 +156,11 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
/// </summary>
private (string enrollmentType, bool isHybridJoin, int? autopilotMode) CollectAutopilotProfile()
{
var detectedType = "v1";
// Single source of truth for the rail classification — the SAME detector the
// session registration uses (incl. the WDP DevicePreparation subkey probe). The
// previous inline CloudAssigned*-only re-implementation kept reporting "v1" on
// Device Preparation devices while the session row already said "v2" (a11102f4).
var (detectedType, detectionRule) = AutopilotMonitor.Agent.V2.Core.Security.EnrollmentRegistryDetector.DetectEnrollmentTypeWithRule();
var isHybridJoin = false;
int? detectedAutopilotMode = null;
@@ -188,20 +192,6 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
}
}
// Extract key values for enrollment type detection
var deviceReg = data.ContainsKey("CloudAssignedDeviceRegistration")
? data["CloudAssignedDeviceRegistration"]?.ToString()
: null;
var espEnabled = data.ContainsKey("CloudAssignedEspEnabled")
? data["CloudAssignedEspEnabled"]?.ToString()
: null;
// Determine enrollment type: WDP if DeviceRegistration=2 or ESP explicitly disabled
if (deviceReg == "2" || espEnabled == "0")
detectedType = "v2";
else
detectedType = "v1";
// --- Interpret AutopilotMode (profile capability, not live state) ---
// 0 = User Driven, 1 = Self Deploying, 2 = Pre-Provisioning (White Glove)
var autopilotModeStr = data.ContainsKey("AutopilotMode")
@@ -377,11 +367,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo
{ "enrollmentType", detectedType },
{ "CloudAssignedDeviceRegistration", data.ContainsKey("CloudAssignedDeviceRegistration") ? data["CloudAssignedDeviceRegistration"] : "n/a" },
{ "CloudAssignedEspEnabled", data.ContainsKey("CloudAssignedEspEnabled") ? data["CloudAssignedEspEnabled"] : "n/a" },
{ "detectionRule", detectedType == "v2"
? (data.ContainsKey("CloudAssignedDeviceRegistration") && data["CloudAssignedDeviceRegistration"]?.ToString() == "2"
? "CloudAssignedDeviceRegistration=2"
: "CloudAssignedEspEnabled=0")
: "default (no v2 indicators)" }
{ "detectionRule", detectionRule }
});
_logger.Info($"EnrollmentTracker: enrollment type detected: {detectedType}");
@@ -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
@@ -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
{
/// <summary>
/// Hosts the <see cref="ImeRegistryAppStateObserver"/> (registry second pillar, audit
/// 2026-08-17): one recursive <see cref="RegistryWatcher"/> 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 <c>collector_degraded</c>). Always-on observability host, no config
/// gate — precedent: <see cref="EspPolicyProviderStallHost"/>.
/// </summary>
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<IReadOnlyList<AppPackageState>>? 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();
}
}
@@ -186,7 +186,15 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
/// Defaults to <c>"v1"</c> on any error so a flaky registry does not misclassify a
/// classic session as WDP.
/// </summary>
public static string DetectEnrollmentType()
public static string DetectEnrollmentType() => DetectEnrollmentTypeWithRule().enrollmentType;
/// <summary>
/// Same as <see cref="DetectEnrollmentType"/> but also returns WHICH rule fired, so the
/// enrollment_type_detected event can name its evidence instead of re-implementing the
/// detection (the duplicate inline logic in DeviceInfoCollector missed the WDP subkey
/// rule and kept reporting "v1" on Device Preparation devices — session a11102f4).
/// </summary>
public static (string enrollmentType, string detectionRule) DetectEnrollmentTypeWithRule()
{
try
{
@@ -194,25 +202,26 @@ namespace AutopilotMonitor.Agent.V2.Core.Security
AutopilotSettingsKey + @"\DevicePreparation\BootstrapperAgent"))
{
var executionContext = wdpKey?.GetValue("ExecutionContext")?.ToString();
if (!string.IsNullOrEmpty(executionContext)) return "v2";
if (!string.IsNullOrEmpty(executionContext))
return ("v2", @"DevicePreparation\BootstrapperAgent\ExecutionContext present");
}
using (var key = Registry.LocalMachine.OpenSubKey(AutopilotSettingsKey))
{
if (key == null) return "v1";
if (key == null) return ("v1", "AutopilotSettings key missing");
var deviceReg = key.GetValue("CloudAssignedDeviceRegistration")?.ToString();
if (deviceReg == "2") return "v2";
if (deviceReg == "2") return ("v2", "CloudAssignedDeviceRegistration=2");
var espEnabled = key.GetValue("CloudAssignedEspEnabled")?.ToString();
if (espEnabled == "0") return "v2";
if (espEnabled == "0") return ("v2", "CloudAssignedEspEnabled=0");
}
}
catch
{
// Fall through to v1 default.
}
return "v1";
return ("v1", "default (no v2 indicators)");
}
}
}
@@ -3,6 +3,7 @@ using System;
using System.Collections.Generic;
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.Orchestration;
using AutopilotMonitor.DecisionCore.Engine;
using AutopilotMonitor.DecisionCore.Signals;
@@ -75,6 +76,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<AppPackageState, DateTime?>? _prevOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState>? _prevOnScriptCompleted;
private readonly Action<ScriptStartedInfo>? _prevOnScriptStarted;
private readonly Action<string, string>? _prevOnImeTokenFailure;
// Our own delegate instances — stored once so Dispose can compare by reference.
private readonly Action<string> _ourOnEspPhaseChanged;
@@ -85,6 +87,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
private readonly Action<AppPackageState, DateTime?> _ourOnDoTelemetryReceived;
private readonly Action<ScriptExecutionState> _ourOnScriptCompleted;
private readonly Action<ScriptStartedInfo> _ourOnScriptStarted;
private readonly Action<string, string> _ourOnImeTokenFailure;
// Dedup state for DecisionSignals.
private string? _lastEspPhase;
@@ -97,6 +100,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
// bootstrap marker line. Lets MCP report "which device runs which bootstrap version".
private bool _bootstrapDetectedPosted;
// Cached EnterpriseDesktopAppManagement-CSP lookup for the IME install source — the
// version event fires more than once per session (V1 parity, no dedup) and the registry
// answer is stable per version, so the probe runs once per distinct version string.
private string? _msiSourceCachedForVersion;
private ImeMsiInstallSource _msiSourceCache;
// One-shot: historic script replay detected (a previous enrollment's IME log content
// surviving on disk) — its script events are suppressed, and a single
// historic_ime_replay_detected Info event explains the gap in the timeline.
@@ -168,6 +177,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_prevOnDoTelemetryReceived = _tracker.OnDoTelemetryReceived;
_prevOnScriptCompleted = _tracker.OnScriptCompleted;
_prevOnScriptStarted = _tracker.OnScriptStarted;
_prevOnImeTokenFailure = _tracker.OnImeTokenFailure;
// Store our delegate instances once — implicit method-group conversions
// create a new delegate each time, which would break Dispose's reference check.
@@ -179,6 +189,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_ourOnDoTelemetryReceived = OnDoTelemetryReceived;
_ourOnScriptCompleted = OnScriptCompleted;
_ourOnScriptStarted = OnScriptStarted;
_ourOnImeTokenFailure = OnImeTokenFailure;
_tracker.OnEspPhaseChanged = _ourOnEspPhaseChanged;
_tracker.OnUserSessionCompleted = _ourOnUserSessionCompleted;
@@ -188,6 +199,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnDoTelemetryReceived = _ourOnDoTelemetryReceived;
_tracker.OnScriptCompleted = _ourOnScriptCompleted;
_tracker.OnScriptStarted = _ourOnScriptStarted;
_tracker.OnImeTokenFailure = _ourOnImeTokenFailure;
}
public void Dispose()
@@ -210,6 +222,8 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
_tracker.OnScriptCompleted = _prevOnScriptCompleted;
if (ReferenceEquals(_tracker.OnScriptStarted, _ourOnScriptStarted))
_tracker.OnScriptStarted = _prevOnScriptStarted;
if (ReferenceEquals(_tracker.OnImeTokenFailure, _ourOnImeTokenFailure))
_tracker.OnImeTokenFailure = _prevOnImeTokenFailure;
}
private void OnEspPhaseChanged(string phase)
@@ -260,6 +274,12 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitScriptStarted(info);
}
private void OnImeTokenFailure(string errorCode, string message)
{
_prevOnImeTokenFailure?.Invoke(errorCode, message);
EmitImeTokenFailure(errorCode, message);
}
internal void TriggerEspPhaseFromTest(string phase) => EmitEspPhase(phase);
internal void TriggerUserSessionCompletedFromTest() => EmitUserSessionCompleted();
internal void TriggerAppStateFromTest(AppPackageState app, AppInstallationState oldState, AppInstallationState newState) =>
@@ -270,6 +290,7 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
EmitDoTelemetry(app, sourceTimestampUtc);
internal void TriggerScriptCompletedFromTest(ScriptExecutionState script) => EmitScriptCompleted(script);
internal void TriggerScriptStartedFromTest(ScriptStartedInfo info) => EmitScriptStarted(info);
internal void TriggerImeTokenFailureFromTest(string errorCode, string message) => EmitImeTokenFailure(errorCode, message);
/// <summary>
/// Prefer the CMTrace log entry timestamp recorded by the most recent pattern match
@@ -864,6 +885,19 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId))
data["patternId"] = patternId!;
// The EnterpriseDesktopAppManagement CSP records where it downloaded the IME MSI
// from — attach that install source so the event answers "which endpoint delivered
// exactly this build" (helps attribute bad builds to a CDN/AFD rollout ring).
var source = ResolveMsiInstallSource(version);
if (source.HasData)
{
if (!string.IsNullOrEmpty(source.DownloadUrl)) data["msiDownloadUrl"] = source.DownloadUrl;
if (!string.IsNullOrEmpty(source.ProductCode)) data["msiProductCode"] = source.ProductCode;
if (!string.IsNullOrEmpty(source.ProductVersion)) data["msiProductVersion"] = source.ProductVersion;
data["msiMatchedBy"] = source.MatchedByVersion ? "productVersion" : "fileName";
}
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
_post.Emit(
@@ -874,7 +908,45 @@ namespace AutopilotMonitor.Agent.V2.Core.SignalAdapters
occurredAtUtc: now);
// PR3-D4: fire-once and useful for compatibility-debugging — INFO level.
_logger?.Info($"ImeAdapter: IME agent version detected: {version}");
_logger?.Info(source.HasData && !string.IsNullOrEmpty(source.DownloadUrl)
? $"ImeAdapter: IME agent version detected: {version} (CSP install source: {source.DownloadUrl})"
: $"ImeAdapter: IME agent version detected: {version}");
}
// IME token-acquisition failure ("Failed to get AAD token ... errorCode = N",
// IntuneTokenManager). Previously we only matched the success line — an outage
// (WAM broken, conditional access, network) was invisible while every check-in
// silently did nothing. Tracker-side dedup: once per distinct errorCode.
private void EmitImeTokenFailure(string errorCode, string message)
{
var now = ResolveOccurredAt(out var derivedFromClock, out var rawSourceTs);
var data = new Dictionary<string, string>(StringComparer.Ordinal)
{
["errorCode"] = string.IsNullOrEmpty(errorCode) ? "unknown" : errorCode,
};
if (!string.IsNullOrEmpty(message))
data["logLine"] = message;
var patternId = _tracker.LastMatchedPatternId;
if (!string.IsNullOrEmpty(patternId))
data["patternId"] = patternId!;
TagDerivedTimestamp(data, derivedFromClock, rawSourceTs);
_post.Emit(
eventType: SharedEventTypes.ImeTokenFailure,
source: SourceLabel,
message: $"IME token acquisition failed (errorCode={data["errorCode"]})",
data: data,
severity: EventSeverity.Warning,
occurredAtUtc: now);
}
private ImeMsiInstallSource ResolveMsiInstallSource(string version)
{
if (_msiSourceCachedForVersion == version) return _msiSourceCache;
_msiSourceCache = ImeMsiInstallSourceProbe.Read(version, _logger);
_msiSourceCachedForVersion = version;
return _msiSourceCache;
}
private void EmitDoTelemetry(AppPackageState app, DateTime? sourceTimestampUtc)
@@ -30,6 +30,8 @@ namespace AutopilotMonitor.Agent.V2
"IntuneManagementExtension-????????-??????.log",
"AppWorkload.log",
"AppWorkload-????????-??????.log",
"AppActionProcessor.log",
"AppActionProcessor-????????-??????.log",
"AgentExecutor.log",
"AgentExecutor-????????-??????.log",
"HealthScripts.log",
@@ -172,12 +172,13 @@ namespace AutopilotMonitor.Agent.V2
logger.Info($"Scheduled Task '{taskName}' created/updated successfully.");
// Two-tier runtime-launch fallback chain:
// Two-tier runtime-launch fallback chain (order is token-aware, see below):
//
// 1. WMI Win32_Process.Create (first choice). Spawns the process from
// the WBEM service context, outside the install-mode process tree
// and outside Task Scheduler's queue (which defers /Run on battery
// / OOBE — observed WinPE 2026-05-04, project_v2_install_runtime_handoff.md).
// 1. WMI Win32_Process.Create (first choice on a full token). Spawns the
// process from the WBEM service context, outside the install-mode
// process tree and outside Task Scheduler's queue (which defers /Run
// on battery / OOBE — observed WinPE 2026-05-04,
// project_v2_install_runtime_handoff.md).
// 2. schtasks /Run on the registered BootTrigger task (fallback).
// Used when WMI is blocked, typically Defender ASR rule
// d1e49aac-8f56-4280-b9ba-993a6d77406c ('Block process creations
@@ -190,6 +191,18 @@ namespace AutopilotMonitor.Agent.V2
// the runtime via the Task Scheduler service — different
// ATT&CK signature than either WMI or a manual schtasks /Run.
//
// Token-aware ordering (2026-08-17): WMI duplicates the CALLER's token onto
// the new process. MDM-originated install chains (Bootstrap MSI via
// EnterpriseDesktopAppManagement CSP → msiexec) carry a SYSTEM token with
// privileges REMOVED (SeTimeZonePrivilege, SeSystemEnvironmentPrivilege) —
// a WMI-launched runtime then runs degraded for its whole life (tzutil
// exit 5, firmware reads privilege_denied). The Task Scheduler service
// starts the task with a fresh full SYSTEM token, so on a stripped token
// the order flips: schtasks /Run first (verified via process poll, since
// /Run only reports "queued"), WMI as degraded-but-alive fallback. The
// battery/OOBE queue-defer that motivated WMI-first is mitigated by the
// hardened task XML (PR2: battery limits off, StartWhenAvailable, PT0S).
//
// Critical contract: --install returns 0 if deployment + task creation
// succeeded, even if both immediate launch paths failed. Returning 1
// here would make the bootstrap script throw, the next IME run would
@@ -197,11 +210,27 @@ namespace AutopilotMonitor.Agent.V2
// sit without a runtime until manual intervention. The bootstrap
// script's Get-Process probe is the canonical "no runtime came up"
// signal — see Install-AutopilotMonitor.ps1 ~line 280.
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
var launch = DecideRuntimeLaunchOutcome(
wmiReturnValue: wmi.ReturnValue,
wmiPid: wmi.Pid,
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
RuntimeLaunchResult launch;
if (!TokenPrivilegeProbe.IsPrivilegeHeld(TokenPrivilegeProbe.TimeZonePrivilege))
{
logger.Warning(
"Install-mode process token does not hold SeTimeZonePrivilege — privilege-stripped " +
"caller chain (typical: MDM LOB MSI install). WMI Win32_Process.Create would duplicate " +
"this token onto the runtime, so the Scheduled Task launch (fresh full SYSTEM token from " +
"the Task Scheduler service) is preferred for this install.");
launch = DecideRestrictedTokenLaunchOutcome(
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger),
verifyRuntimePid: () => WaitForDetachedRuntimePid(targetExePath, logger),
tryWmi: () => TryStartRuntimeViaWmi(targetExePath, logger));
}
else
{
var wmi = TryStartRuntimeViaWmi(targetExePath, logger);
launch = DecideRuntimeLaunchOutcome(
wmiReturnValue: wmi.ReturnValue,
wmiPid: wmi.Pid,
trySchtasks: () => TryStartRuntimeViaSchtasks(taskName, logger));
}
switch (launch.Method)
{
@@ -211,7 +240,7 @@ namespace AutopilotMonitor.Agent.V2
break;
case RuntimeLaunchMethod.Schtasks:
logger.Info(
$"Runtime process queued via schtasks /Run fallback on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
$"Runtime start via schtasks /Run on task '{taskName}'. exe={targetExePath}. {launch.Diagnostic}");
break;
case RuntimeLaunchMethod.Deferred:
logger.Warning(
@@ -629,11 +658,113 @@ namespace AutopilotMonitor.Agent.V2
diagnostic: $"Both immediate-launch paths failed; deferring runtime start to BootTrigger Scheduled Task on next reboot. {wmiHint} schtasks /Run exit={schtasksExit}.");
}
/// <summary>
/// Restricted-token variant of the launch orchestration — used when the install-mode
/// process token does not hold <c>SeTimeZonePrivilege</c> (privilege-stripped MDM/MSI
/// caller chain, observed 2026-08-17). WMI would duplicate the stripped token onto the
/// runtime, so the order flips: Scheduled Task first (fresh full SYSTEM token from the
/// Task Scheduler service), WMI only as degraded-but-alive fallback.
/// <para>
/// Contract:
/// <list type="bullet">
/// <item><description>Invoke <paramref name="trySchtasks"/>; on exit 0 invoke <paramref name="verifyRuntimePid"/> (schtasks /Run only reports "queued", never "launched"). A verified pid returns <see cref="RuntimeLaunchMethod.Schtasks"/> without touching WMI.</description></item>
/// <item><description>schtasks failed or no process appeared → invoke <paramref name="tryWmi"/>; a valid pid returns <see cref="RuntimeLaunchMethod.Wmi"/> with a diagnostic warning that the runtime runs on the restricted token.</description></item>
/// <item><description>Both paths fail → <see cref="RuntimeLaunchMethod.Deferred"/>; the BootTrigger start on next reboot gets a full token anyway.</description></item>
/// <item><description><paramref name="verifyRuntimePid"/> is only invoked after a successful schtasks queue — it costs up to the full poll window.</description></item>
/// </list>
/// </para>
/// </summary>
internal static RuntimeLaunchResult DecideRestrictedTokenLaunchOutcome(
Func<int> trySchtasks,
Func<int> verifyRuntimePid,
Func<WmiLaunchAttempt> tryWmi)
{
if (trySchtasks == null) throw new ArgumentNullException(nameof(trySchtasks));
if (verifyRuntimePid == null) throw new ArgumentNullException(nameof(verifyRuntimePid));
if (tryWmi == null) throw new ArgumentNullException(nameof(tryWmi));
var schtasksExit = trySchtasks();
if (schtasksExit == 0)
{
var pid = verifyRuntimePid();
if (pid > 0)
{
return new RuntimeLaunchResult(
method: RuntimeLaunchMethod.Schtasks,
pid: pid,
wmiReturnValue: 0u,
schtasksExitCode: 0,
diagnostic: $"Runtime launched via Scheduled Task (restricted install token — task provides a full SYSTEM token). Verified PID={pid}.");
}
}
var schtasksHint = schtasksExit == 0
? "schtasks /Run was queued but no runtime process appeared within the verification window."
: $"schtasks /Run exit={schtasksExit}.";
var wmi = tryWmi();
if (wmi.ReturnValue == 0u && wmi.Pid > 0)
{
return new RuntimeLaunchResult(
method: RuntimeLaunchMethod.Wmi,
pid: wmi.Pid,
wmiReturnValue: 0u,
schtasksExitCode: schtasksExit,
diagnostic: "Runtime launched via WMI fallback ON THE RESTRICTED TOKEN — privileged features " +
"(timezone auto-set, firmware reads) stay degraded until the next BootTrigger start. " +
$"{schtasksHint} PID={wmi.Pid}.");
}
return new RuntimeLaunchResult(
method: RuntimeLaunchMethod.Deferred,
pid: 0,
wmiReturnValue: wmi.ReturnValue,
schtasksExitCode: schtasksExit,
diagnostic: "Restricted-token launch: both Scheduled Task and WMI paths failed; deferring to " +
$"BootTrigger on next reboot (full token there). {schtasksHint} WMI returnValue={wmi.ReturnValue}.");
}
/// <summary>
/// Polls for the detached runtime process after a <c>schtasks /Run</c> queue — same
/// 10s / 500ms contract as the bootstrap script's Get-Process probe. The install-mode
/// process shares the runtime's exe name, so the own pid is excluded; a pre-existing
/// foreign runtime cannot be running here because install mode bails earlier on the
/// Deployed marker.
/// </summary>
private static int WaitForDetachedRuntimePid(string exePath, AgentLogger logger)
{
var processName = Path.GetFileNameWithoutExtension(exePath);
var ownPid = Process.GetCurrentProcess().Id;
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline)
{
try
{
var candidates = Process.GetProcessesByName(processName);
try
{
var runtime = candidates.FirstOrDefault(p => p.Id != ownPid);
if (runtime != null) return runtime.Id;
}
finally
{
foreach (var p in candidates) p.Dispose();
}
}
catch (Exception ex)
{
logger.Debug($"Runtime verification poll failed transiently: {ex.GetType().Name}: {ex.Message}");
}
System.Threading.Thread.Sleep(500);
}
return 0;
}
/// <summary>
/// Result of a single WMI <c>Win32_Process.Create</c> attempt. <see cref="Pid"/>
/// is <c>0</c> on any non-zero <see cref="ReturnValue"/>.
/// </summary>
private readonly struct WmiLaunchAttempt
internal readonly struct WmiLaunchAttempt
{
public WmiLaunchAttempt(uint returnValue, int pid)
{
@@ -0,0 +1,130 @@
using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;
namespace AutopilotMonitor.Agent.V2
{
/// <summary>
/// Checks whether the current process token HOLDS a privilege — present in the token at
/// all, enabled or disabled. This is different from the enable-on-demand pattern in
/// <c>UefiSecureBootCertReader</c>: a privilege that was REMOVED from the token cannot be
/// re-enabled with <c>AdjustTokenPrivileges</c> by anyone, including SYSTEM.
/// <para>
/// Why install mode needs this: MDM-originated install chains (EnterpriseDesktopAppManagement
/// CSP → msiexec → bootstrap runner) carry a SYSTEM token with several privileges removed —
/// observed 2026-08-17 with the Bootstrap MSI: SeTimeZonePrivilege and
/// SeSystemEnvironmentPrivilege were gone, so tzutil exited 5 (ACCESS_DENIED) and firmware
/// reads reported privilege_denied. WMI <c>Win32_Process.Create</c> duplicates the CALLER's
/// token onto the new process, so the restriction propagates to the runtime; the Task
/// Scheduler service, by contrast, starts the task with a fresh full SYSTEM token. The
/// launch orchestration therefore needs to know up front whether its own token is stripped.
/// </para>
/// <para>
/// Fail-soft: every API failure reports "held" so the default (WMI-first) launch path is
/// kept — a probe defect must never change behavior for the healthy platform-script chain.
/// </para>
/// </summary>
internal static class TokenPrivilegeProbe
{
public const string TimeZonePrivilege = "SeTimeZonePrivilege";
public static bool IsPrivilegeHeld(string privilegeName)
{
try
{
NativeMethods.LUID target;
if (!NativeMethods.LookupPrivilegeValueW(null, privilegeName, out target))
return true;
SafeTokenHandle token;
if (!NativeMethods.OpenProcessToken(NativeMethods.GetCurrentProcess(),
NativeMethods.TOKEN_QUERY, out token))
{
if (token != null) token.Dispose();
return true;
}
using (token)
{
uint required;
NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
IntPtr.Zero, 0, out required);
if (required == 0 || required > 64 * 1024)
return true;
var buffer = Marshal.AllocHGlobal((int)required);
try
{
if (!NativeMethods.GetTokenInformation(token, NativeMethods.TokenPrivileges,
buffer, required, out required))
return true;
// TOKEN_PRIVILEGES layout: DWORD PrivilegeCount, then PrivilegeCount ×
// LUID_AND_ATTRIBUTES { LUID (uint Low, int High), DWORD Attributes } = 12 bytes.
var count = Marshal.ReadInt32(buffer);
for (var i = 0; i < count; i++)
{
var entry = 4 + i * 12;
var low = unchecked((uint)Marshal.ReadInt32(buffer, entry));
var high = Marshal.ReadInt32(buffer, entry + 4);
if (low == target.LowPart && high == target.HighPart)
return true;
}
return false;
}
finally
{
Marshal.FreeHGlobal(buffer);
}
}
}
catch
{
return true;
}
}
private sealed class SafeTokenHandle : SafeHandleZeroOrMinusOneIsInvalid
{
public SafeTokenHandle() : base(true) { }
protected override bool ReleaseHandle()
{
return NativeMethods.CloseHandle(handle);
}
}
private static class NativeMethods
{
public const uint TOKEN_QUERY = 0x0008;
public const int TokenPrivileges = 3; // TOKEN_INFORMATION_CLASS.TokenPrivileges
[StructLayout(LayoutKind.Sequential)]
public struct LUID
{
public uint LowPart;
public int HighPart;
}
[DllImport("kernel32.dll")]
public static extern IntPtr GetCurrentProcess();
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool OpenProcessToken(IntPtr processHandle, uint desiredAccess, out SafeTokenHandle tokenHandle);
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool LookupPrivilegeValueW(string lpSystemName, string lpName, out LUID lpLuid);
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool GetTokenInformation(SafeTokenHandle tokenHandle, int tokenInformationClass,
IntPtr tokenInformation, uint tokenInformationLength, out uint returnLength);
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool CloseHandle(IntPtr handle);
}
}
}
@@ -0,0 +1,55 @@
# Invoke-BootstrapDownload.ps1 - thin runner embedded in the Bootstrap MSI.
#
# The MSI is only a delivery vehicle (MDM LOB channel installs earlier than the IME
# script/app batches during Autopilot Device Preparation). This runner downloads the
# CURRENT Install-AutopilotMonitor.ps1 from the download endpoint and executes it, so
# every piece of real bootstrap logic (guards, relax rules, agent download) stays
# server-updatable without re-shipping the MSI.
#
# Fail-soft by design: the runner always exits 0 so a transient network failure never
# marks the LOB install as failed; diagnostics go to msi-bootstrap.log.
# ASCII only - no special characters in this file.
$ErrorActionPreference = 'Stop'
$baseDir = Join-Path $env:ProgramData 'AutopilotMonitor'
$logDir = Join-Path $baseDir 'Logs'
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$log = Join-Path $logDir 'msi-bootstrap.log'
function Write-Log([string]$message) {
Add-Content -Path $log -Value ("[{0}] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $message)
}
try {
Write-Log "MSI bootstrap runner started (user=$env:USERNAME, pid=$PID)."
if (Test-Path 'HKLM:\SOFTWARE\AutopilotMonitor\Deployed') {
Write-Log 'Deployed marker present - skipping, another channel already installed the agent.'
exit 0
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$url = 'https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
$dest = Join-Path $baseDir 'Install-AutopilotMonitor.ps1'
for ($attempt = 1; $attempt -le 3; $attempt++) {
try {
Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $dest
break
} catch {
Write-Log "Download attempt $attempt failed: $_"
if ($attempt -eq 3) { throw }
Start-Sleep -Seconds 10
}
}
Write-Log "Bootstrap script downloaded to $dest - executing."
# PS 5.1 file redirection (*>>) writes UTF-16LE, which garbles a log otherwise
# written via Add-Content (ANSI). Route through Add-Content for one encoding.
& $dest *>&1 | ForEach-Object { $_.ToString() } | Add-Content -Path $log
Write-Log "Bootstrap script finished (exit code $LASTEXITCODE)."
exit 0
} catch {
Write-Log ("MSI bootstrap runner failed: " + ($_ | Out-String))
exit 0
}
@@ -0,0 +1,54 @@
<!--
Autopilot Monitor Bootstrap MSI - thin MDM LOB delivery vehicle for WDP (Autopilot v2).
LOB MSIs are installed by the OMA-DM stack (EnterpriseDesktopAppManagement CSP) during
the first MDM sync, i.e. BEFORE/parallel to the Device Preparation SLDM app batch -
earlier than any IME-delivered script (Batch 2) or Win32 app can run. The MSI itself
carries no bootstrap logic: it installs one runner script and executes it once. The
runner downloads the CURRENT server-hosted Install-AutopilotMonitor.ps1, keeping all
real logic updatable without re-shipping the MSI.
Build: wix build Package.wxs -arch x64 -o bin\AutopilotMonitor-Bootstrap.msi
-->
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
<Package Name="Autopilot Monitor Bootstrap"
Manufacturer="Autopilot Monitor"
Version="1.0.0"
UpgradeCode="1fbe5515-d346-445c-b8b8-14ee79f97522"
Scope="perMachine"
Compressed="yes">
<MajorUpgrade DowngradeErrorMessage="A newer version of Autopilot Monitor Bootstrap is already installed." />
<MediaTemplate EmbedCab="yes" />
<StandardDirectory Id="CommonAppDataFolder">
<Directory Id="CompanyFolder" Name="AutopilotMonitor">
<Directory Id="INSTALLFOLDER" Name="Msi" />
</Directory>
</StandardDirectory>
<ComponentGroup Id="MainComponents" Directory="INSTALLFOLDER">
<Component Id="RunnerScript" Guid="8c8e8962-719f-458b-b291-dd99d3f1dc30">
<File Id="RunnerPs1" Source="Invoke-BootstrapDownload.ps1" KeyPath="yes" />
</Component>
</ComponentGroup>
<Feature Id="Main" Level="1">
<ComponentGroupRef Id="MainComponents" />
</Feature>
<!-- Deferred + no-impersonate: runs as SYSTEM after files land. Return=ignore keeps
the LOB install green even if the runner hits a transient error (the runner is
additionally fail-soft itself); diagnostics live in msi-bootstrap.log. -->
<CustomAction Id="RunBootstrap"
Directory="INSTALLFOLDER"
ExeCommand="&quot;[System64Folder]WindowsPowerShell\v1.0\powershell.exe&quot; -NoProfile -ExecutionPolicy Bypass -File &quot;[INSTALLFOLDER]Invoke-BootstrapDownload.ps1&quot;"
Execute="deferred"
Impersonate="no"
Return="ignore" />
<InstallExecuteSequence>
<Custom Action="RunBootstrap" After="InstallFiles" Condition="NOT Installed" />
</InstallExecuteSequence>
</Package>
</Wix>
@@ -0,0 +1,44 @@
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// Default-value tests for the IME-installer archiving settings. The flag gates the
/// ime-msi-archive queue worker (pause, not drop); the size cap guards the download.
/// Defaulting the flag to TRUE is deliberate: archiving is operator infrastructure with
/// ~one small download per Microsoft IME release, and a version missed while the flag is
/// off is gone for good (the versionless CDN moves on).
/// </summary>
public class AdminConfigurationImeMsiArchivingTests
{
[Fact]
public void ImeMsiArchivingEnabled_defaults_to_true_on_new_config()
{
var cfg = new AdminConfiguration();
Assert.True(cfg.ImeMsiArchivingEnabled);
}
[Fact]
public void ImeMsiArchivingEnabled_defaults_to_true_on_CreateDefault()
{
var cfg = AdminConfiguration.CreateDefault();
Assert.True(cfg.ImeMsiArchivingEnabled);
}
[Fact]
public void MaxImeMsiDownloadSizeMB_defaults_to_250()
{
// 250 MB: ~20x today's MSI so a legitimately grown installer is never missed,
// while a tampered URL still cannot stream gigabytes (Oliver, 2026-08-18).
Assert.Equal(250, new AdminConfiguration().MaxImeMsiDownloadSizeMB);
Assert.Equal(250, AdminConfiguration.CreateDefault().MaxImeMsiDownloadSizeMB);
}
[Fact]
public void Settings_persist_on_the_config()
{
var cfg = new AdminConfiguration { ImeMsiArchivingEnabled = false, MaxImeMsiDownloadSizeMB = 50 };
Assert.False(cfg.ImeMsiArchivingEnabled);
Assert.Equal(50, cfg.MaxImeMsiDownloadSizeMB);
}
}
@@ -171,7 +171,7 @@ public class BuiltInRulesTests
"updateStatePostponed", "espPhaseDetected", "imeStarted",
"policiesDiscovered", "ignoreCompletedApp", "imeAgentVersion",
"espTrackStatus", "updateName", "updateWin32AppState",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation",
"cancelStuckAndSetCurrent", "imeSessionChange", "imeImpersonation", "imeTokenFailure",
"enrollmentCompleted", "updateDoTelemetry", "scriptStarted",
"scriptContext", "scriptExitCode", "scriptOutput", "scriptCompleted",
"resetPlatformScriptContext",
@@ -174,6 +174,23 @@ public class EndpointPolicyCatalogCompletenessTests
Assert.Equal(EndpointPolicy.TenantAdminOrGA, postRules.Policy);
}
/// <summary>
/// IME log pattern edits write GLOBAL patterns (all tenants) and the handler has no GA
/// re-gate, so the PUT must stay GlobalAdminOnly — a downgrade to TenantAdminOrGA would let
/// any customer tenant admin mutate platform-wide patterns via ?global=true.
/// </summary>
[Fact]
public void ImeLogPatternWrites_AreGlobalAdminOnly()
{
var putPattern = EndpointAccessPolicyCatalog.FindPolicy("PUT", "/api/rules/ime-log-patterns/IME-001");
var reseed = EndpointAccessPolicyCatalog.FindPolicy("POST", "/api/rules/ime-log-patterns/reseed");
Assert.NotNull(putPattern);
Assert.NotNull(reseed);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, putPattern.Policy);
Assert.Equal(EndpointPolicy.GlobalAdminOnly, reseed.Policy);
}
/// <summary>
/// Unregistered routes must return null (fail-closed).
/// </summary>
@@ -39,6 +39,26 @@ public class GetImeVersionHistoryRedactionTests
Assert.DoesNotContain("firstSeenSessionId", json, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The MSI-archive columns (ImeMsiArchiver) are operator infrastructure — blob paths,
/// hashes and source URLs of the platform's own archive have no business in a tenant
/// member's payload. Pinned like the identifiers so the boundary survives refactors.
/// </summary>
[Fact]
public void NonGlobalCaller_DropsMsiArchiveFields()
{
var json = SerializeFor(hasGlobalScope: false, MakeEntry());
Assert.DoesNotContain("msiArchiveStatus", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiArchiveBlobPath", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiSha256", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiBytes", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("msiSourceUrl", json, StringComparison.OrdinalIgnoreCase);
// Values too, not just keys (same rationale as the identifier-value test below).
Assert.DoesNotContain("4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e", json, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("imeswdb-afd-secondary", json, StringComparison.OrdinalIgnoreCase);
}
/// <summary>
/// The values, not just the property names: a rename of the model property must not let the
/// identifier survive under a different key. Asserting on the GUIDs catches that.
@@ -101,7 +121,12 @@ public class GetImeVersionHistoryRedactionTests
var keys = doc.RootElement[0].EnumerateObject().Select(p => p.Name).OrderBy(n => n).ToArray();
Assert.Equal(
new[] { "firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt", "sessionCount", "version" },
new[]
{
"firstSeenAt", "firstSeenSessionId", "firstSeenTenantId", "lastSeenAt",
"msiArchiveBlobPath", "msiArchiveStatus", "msiBytes", "msiSha256", "msiSourceUrl",
"sessionCount", "version",
},
keys);
}
@@ -185,5 +210,10 @@ public class GetImeVersionHistoryRedactionTests
FirstSeenTenantId = "57f34dd1-6a42-47f9-9bc0-b921fa6caa30",
LastSeenAt = Last,
SessionCount = 3109,
MsiArchiveStatus = "Archived",
MsiArchiveBlobPath = $"{version}/IntuneWindowsAgent.msi",
MsiSha256 = "4d6b85fd3c49cf227ab4711308bca1f3c701cad9bff1799da4c6b6616066bb5e",
MsiBytes = 13_107_200,
MsiSourceUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi",
};
}
@@ -0,0 +1,335 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Security.Cryptography;
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Functions.Services.Ime;
using AutopilotMonitor.Shared.Models;
using Azure;
using Xunit;
namespace AutopilotMonitor.Functions.Tests;
/// <summary>
/// The IME-installer archiver downloads a newly sighted IME build into the permanent
/// ime-archive container. Its contract: NEVER throw (worker decides retry from the result),
/// treat the agent-supplied URL as untrusted (allowlist, else canonical fallback), enforce
/// the size cap both at Content-Length preflight and mid-stream, and stay idempotent under
/// queue re-delivery (409 on the write-once upload == archived). Tests pin every status
/// branch via a recording subclass — no Azurite, no HTTP.
/// </summary>
public class ImeMsiArchiverTests
{
private const string Version = "1.104.102.0";
private const string EventUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
// =========================================================================
// URL allowlist (static — the SSRF boundary)
// =========================================================================
[Theory]
[InlineData(EventUrl, true)]
[InlineData("https://swda01-mscdn.manage.microsoft.com/IntuneWindowsAgent.msi", true)]
[InlineData("https://manage.microsoft.com/IntuneWindowsAgent.msi", true)] // apex host
[InlineData("https://x.manage.microsoft.com/path/IntuneWindowsAgent.msi?sv=1&sig=2", true)] // query ignored
[InlineData("https://x.manage.microsoft.com/intunewindowsagent.MSI", true)] // case-insensitive
[InlineData("http://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi", false)] // https only
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi", false)]
[InlineData("https://manage.microsoft.com.evil.com/IntuneWindowsAgent.msi", false)] // suffix spoof
[InlineData("https://xmanage.microsoft.com/IntuneWindowsAgent.msi", false)] // needs the dot
[InlineData("https://x.manage.microsoft.com/SomethingElse.msi", false)] // wrong filename
[InlineData("https://x.manage.microsoft.com/IntuneWindowsAgent.msi.exe", false)]
[InlineData("not a url", false)]
[InlineData("", false)]
[InlineData(null, false)]
public void IsAllowedMsiUrl_cases(string? url, bool expected)
{
Assert.Equal(expected, ImeMsiArchiver.IsAllowedMsiUrl(url));
}
// =========================================================================
// Version guard (blob path is built from untrusted input)
// =========================================================================
[Theory]
[InlineData("")]
[InlineData("1.104.102.0.1")] // too many components
[InlineData("1.104../escape")]
[InlineData("latest")]
[InlineData("1.104.102.0 ")]
public async Task ArchiveAsync_BadVersion_PermanentFailure_NoDownload(string version)
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(version: version));
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedBadVersion, result.Status);
Assert.False(result.Retryable);
Assert.Equal(0, archiver.DownloadCalls + archiver.UploadCalls);
}
// =========================================================================
// URL selection
// =========================================================================
[Fact]
public async Task ArchiveAsync_AllowedEventUrl_IsUsed()
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(url: EventUrl));
Assert.True(result.Success);
Assert.Equal(EventUrl, archiver.DownloadLastUrl);
Assert.Equal(EventUrl, result.SourceUrl);
}
[Theory]
[InlineData("https://evil.example.com/IntuneWindowsAgent.msi")] // allowlist reject
[InlineData(null)] // pre-enrichment agent
public async Task ArchiveAsync_MissingOrRejectedEventUrl_FallsBackToCanonical(string? url)
{
var archiver = new RecordingArchiver();
var result = await archiver.ArchiveAsync(Envelope(url: url));
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, archiver.DownloadLastUrl);
Assert.Equal(ImeMsiArchiver.CanonicalMsiUrl, result.SourceUrl);
}
// =========================================================================
// Happy path
// =========================================================================
[Fact]
public async Task ArchiveAsync_HappyPath_UploadsMsiAndProvenance_WithHashAndSize()
{
var payload = new byte[3 * 1024 * 1024];
new Random(42).NextBytes(payload);
var expectedSha = Convert.ToHexString(SHA256.HashData(payload)).ToLowerInvariant();
var archiver = new RecordingArchiver { Payload = payload };
var result = await archiver.ArchiveAsync(Envelope());
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
Assert.Equal(expectedSha, result.Sha256);
Assert.Equal(payload.Length, result.SizeBytes);
Assert.Equal(1, archiver.UploadCalls);
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", archiver.UploadLastPath);
Assert.Equal(payload.Length, archiver.UploadedByteCount);
Assert.Equal(1, archiver.ProvenanceCalls);
Assert.Equal($"{Version}/provenance.json", archiver.ProvenanceLastPath);
Assert.Contains(expectedSha, archiver.ProvenanceLastJson);
Assert.Contains(Version, archiver.ProvenanceLastJson);
}
// =========================================================================
// Size cap — preflight and mid-stream
// =========================================================================
[Fact]
public async Task ArchiveAsync_ContentLengthAboveCap_Rejected_NoUpload()
{
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 1,
Payload = new byte[2 * 1024 * 1024],
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedTooLarge, result.Status);
Assert.False(result.Retryable);
Assert.Equal(0, archiver.UploadCalls);
}
[Fact]
public async Task ArchiveAsync_UnknownContentLength_CapStillEnforcedMidStream()
{
// A lying/absent Content-Length must not bypass the cap — the hashing wrapper
// aborts the stream as soon as the byte count crosses it.
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 1,
Payload = new byte[2 * 1024 * 1024],
ReportContentLength = false,
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedTooLarge, result.Status);
Assert.False(result.Retryable);
Assert.Equal(0, archiver.ProvenanceCalls);
}
[Fact]
public async Task ArchiveAsync_CapZero_MeansUnlimited()
{
var archiver = new RecordingArchiver
{
MaxDownloadSizeMB = 0,
Payload = new byte[2 * 1024 * 1024],
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.True(result.Success);
}
// =========================================================================
// Idempotency under queue re-delivery
// =========================================================================
[Fact]
public async Task ArchiveAsync_BlobAlreadyExists_TreatedAsArchived_NotRetryable()
{
var archiver = new RecordingArchiver
{
UploadException = new RequestFailedException(409, "BlobAlreadyExists"),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.True(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.Archived, result.Status);
Assert.False(result.Retryable);
Assert.Equal($"{Version}/IntuneWindowsAgent.msi", result.BlobPath);
}
// =========================================================================
// Transient failures — retryable, never thrown
// =========================================================================
[Fact]
public async Task ArchiveAsync_DownloadFails_RetryableDownloadStatus()
{
var archiver = new RecordingArchiver
{
DownloadException = new HttpRequestException("boom"),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedDownload, result.Status);
Assert.True(result.Retryable);
}
[Fact]
public async Task ArchiveAsync_Timeout_RetryableTimeoutStatus()
{
var archiver = new RecordingArchiver
{
DownloadException = new OperationCanceledException(),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedTimeout, result.Status);
Assert.True(result.Retryable);
}
[Fact]
public async Task ArchiveAsync_UnexpectedUploadError_RetryableErrorStatus()
{
var archiver = new RecordingArchiver
{
UploadException = new InvalidOperationException("storage hiccup"),
};
var result = await archiver.ArchiveAsync(Envelope());
Assert.False(result.Success);
Assert.Equal(ImeMsiArchiver.Statuses.FailedError, result.Status);
Assert.True(result.Retryable);
}
[Fact]
public async Task ArchiveAsync_CallerCancellation_Propagates()
{
// Host shutdown must reach the worker's dispatch loop so the message stays queued.
var archiver = new RecordingArchiver();
using var cts = new CancellationTokenSource();
cts.Cancel();
archiver.DownloadException = new OperationCanceledException(cts.Token);
await Assert.ThrowsAnyAsync<OperationCanceledException>(
() => archiver.ArchiveAsync(Envelope(), cts.Token));
}
// =========================================================================
// Helpers
// =========================================================================
private static ImeMsiArchiveEnvelope Envelope(string version = Version, string? url = EventUrl) =>
new()
{
Version = version,
MsiDownloadUrl = url,
MsiMatchedBy = "productVersion",
TenantId = "11111111-1111-1111-1111-111111111111",
SessionId = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa",
EnqueuedAt = DateTime.UtcNow,
};
private sealed class RecordingArchiver : ImeMsiArchiver
{
public byte[] Payload { get; set; } = new byte[2048];
public bool ReportContentLength { get; set; } = true;
public int MaxDownloadSizeMB { get; set; } = 250;
public Exception? DownloadException { get; set; }
public Exception? UploadException { get; set; }
public int DownloadCalls { get; private set; }
public string? DownloadLastUrl { get; private set; }
public int UploadCalls { get; private set; }
public string? UploadLastPath { get; private set; }
public long UploadedByteCount { get; private set; }
public int ProvenanceCalls { get; private set; }
public string? ProvenanceLastPath { get; private set; }
public string ProvenanceLastJson { get; private set; } = string.Empty;
protected override Task<AdminConfiguration> GetAdminConfigurationAsync()
=> Task.FromResult(new AdminConfiguration { MaxImeMsiDownloadSizeMB = MaxDownloadSizeMB });
protected override Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
string url, CancellationToken cancellationToken)
{
DownloadCalls++;
DownloadLastUrl = url;
if (DownloadException is not null) throw DownloadException;
return Task.FromResult(
((Stream)new MemoryStream(Payload), ReportContentLength ? Payload.LongLength : -1L));
}
protected override async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
{
UploadCalls++;
UploadLastPath = blobPath;
// Drain the stream like the real blob upload would — this is what drives the
// hashing/cap wrapper (and mid-stream aborts surface here as exceptions).
var buffer = new byte[64 * 1024];
int read;
while ((read = await content.ReadAsync(buffer, cancellationToken)) > 0)
UploadedByteCount += read;
if (UploadException is not null) throw UploadException;
}
protected override Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
{
ProvenanceCalls++;
ProvenanceLastPath = blobPath;
ProvenanceLastJson = json;
return Task.CompletedTask;
}
}
}
@@ -818,6 +818,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
{ "VulnerabilityCorrelationEnabled", config.VulnerabilityCorrelationEnabled },
{ "EnableIndexDualWrite", config.EnableIndexDualWrite },
{ "AutoApproveNewTenants", config.AutoApproveNewTenants },
{ "ImeMsiArchivingEnabled", config.ImeMsiArchivingEnabled },
{ "MaxImeMsiDownloadSizeMB", config.MaxImeMsiDownloadSizeMB },
{ "SelfServiceAppHomingEnabled", config.SelfServiceAppHomingEnabled },
{ "SessionDeletionKillSwitch", config.SessionDeletionKillSwitch },
{ "VulnerabilityDataLastSyncUtc", config.VulnerabilityDataLastSyncUtc },
@@ -885,6 +887,8 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
VulnerabilityCorrelationEnabled = entity.GetBoolean("VulnerabilityCorrelationEnabled") ?? true,
EnableIndexDualWrite = entity.GetBoolean("EnableIndexDualWrite") ?? false,
AutoApproveNewTenants = entity.GetBoolean("AutoApproveNewTenants") ?? false,
ImeMsiArchivingEnabled = entity.GetBoolean("ImeMsiArchivingEnabled") ?? true,
MaxImeMsiDownloadSizeMB = entity.GetInt32("MaxImeMsiDownloadSizeMB") ?? 250,
SelfServiceAppHomingEnabled = entity.GetBoolean("SelfServiceAppHomingEnabled") ?? false,
SessionDeletionKillSwitch = entity.GetBoolean("SessionDeletionKillSwitch") ?? false,
VulnerabilityDataLastSyncUtc = entity.GetString("VulnerabilityDataLastSyncUtc"),
@@ -172,6 +172,10 @@ namespace AutopilotMonitor.Functions.DataAccess.TableStorage
public Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync()
=> _storage.GetImeVersionHistoryAsync();
public Task UpdateImeVersionArchiveInfoAsync(
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl)
=> _storage.UpdateImeVersionArchiveInfoAsync(version, status, blobPath, sha256, sizeBytes, sourceUrl);
public async Task<bool> StoreEventAsync(EnrollmentEvent evt)
{
var result = await _storage.StoreEventAsync(evt);
@@ -334,6 +334,26 @@ builder.Services.AddSingleton<
builder.Services.AddHostedService<
AutopilotMonitor.Functions.Services.Vulnerability.VulnerabilityCorrelateQueueWorker>();
// IME-installer archiving on first fleet-wide sighting of a new IME version (~monthly).
// Producer sits in EventIngestProcessor's new-version continuation; the worker downloads
// the MSI from the CSP-reported (allowlisted) URL into the permanent ime-archive container
// and merges the outcome onto the ImeVersionHistory row. Typed HttpClient: no redirects
// (SSRF posture — the allowlisted host must serve directly), long timeout owned by the
// archiver's own CTS, transient retry via the shared external-data policy.
builder.Services.AddHttpClient<AutopilotMonitor.Functions.Services.Ime.ImeMsiArchiver>()
.ConfigurePrimaryHttpMessageHandler(() => new System.Net.Http.SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(10),
})
.ConfigureHttpClient(client => client.Timeout = Timeout.InfiniteTimeSpan)
.AddPolicyHandler((sp, _) => sp.GetRequiredService<ResiliencePolicies>().ExternalDataApi);
builder.Services.AddSingleton<
AutopilotMonitor.Functions.Services.Ime.IImeMsiArchiveProducer,
AutopilotMonitor.Functions.Services.Ime.AzureQueueImeMsiArchiveProducer>();
builder.Services.AddHostedService<
AutopilotMonitor.Functions.Services.Ime.ImeMsiArchiveQueueWorker>();
// Delayed tenant auto-approve (public availability). Producer fires at first-login signup
// with a ~1-minute visibility delay; the worker activates the tenant via the shared
// TenantApprovalService only if AdminConfiguration.AutoApproveNewTenants is enabled at
@@ -311,7 +311,6 @@ public static class EndpointAccessPolicyCatalog
new("POST", "rules/analyze/{ruleId}/create-from-template", EndpointPolicy.TenantAdminOrGA),
new("PUT", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("DELETE", "rules/analyze/{ruleId}", EndpointPolicy.TenantAdminOrGA),
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-failed", EndpointPolicy.TenantAdminOrGA),
new("POST", "sessions/{sessionId}/mark-succeeded", EndpointPolicy.TenantAdminOrGA),
// Operator tier so troubleshooting staff can queue request_diagnostics ("Collect Logs" in
@@ -541,6 +540,9 @@ public static class EndpointAccessPolicyCatalog
new("GET", "vulnerability/ignored-software", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
new("DELETE", "vulnerability/ignored-software", EndpointPolicy.GlobalAdminOnly),
// Both routes mutate GLOBAL patterns (all tenants); there is no tenant-scoped variant yet,
// so PUT must not sit on the TenantAdminOrGA tier — the handler has no GA re-gate.
new("PUT", "rules/ime-log-patterns/{patternId}", EndpointPolicy.GlobalAdminOnly),
new("POST", "rules/ime-log-patterns/reseed", EndpointPolicy.GlobalAdminOnly),
new("GET", "global/mcp-users", EndpointPolicy.GlobalReadOrAdmin),
new("POST", "global/mcp-users", EndpointPolicy.GlobalAdminOnly),
@@ -43,6 +43,7 @@ namespace AutopilotMonitor.Functions.Services
private readonly AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer _analyzeProducer;
private readonly AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry _interimTriggerRegistry;
private readonly IVulnerabilityCorrelateProducer _vulnProducer;
private readonly Ime.IImeMsiArchiveProducer _imeMsiArchiveProducer;
public EventIngestProcessor(
ILogger<EventIngestProcessor> logger,
@@ -58,7 +59,8 @@ namespace AutopilotMonitor.Functions.Services
TelemetryClient telemetryClient,
AutopilotMonitor.Functions.Services.Analyze.IAnalyzeOnEnrollmentEndProducer analyzeProducer,
AutopilotMonitor.Functions.Services.Analyze.InterimTriggerRegistry interimTriggerRegistry,
IVulnerabilityCorrelateProducer vulnProducer)
IVulnerabilityCorrelateProducer vulnProducer,
Ime.IImeMsiArchiveProducer imeMsiArchiveProducer)
{
_logger = logger;
_sessionRepo = sessionRepo;
@@ -74,6 +76,7 @@ namespace AutopilotMonitor.Functions.Services
_analyzeProducer = analyzeProducer;
_interimTriggerRegistry = interimTriggerRegistry;
_vulnProducer = vulnProducer;
_imeMsiArchiveProducer = imeMsiArchiveProducer;
}
/// <summary>
@@ -129,6 +132,14 @@ namespace AutopilotMonitor.Functions.Services
.ContinueWith(t => _logger.LogWarning(t.Exception?.InnerException,
"ImeAgentVersion update failed (non-fatal)"), TaskContinuationOptions.OnlyOnFaulted);
// Captured here (not inside the continuation) purely for clarity — the agent's
// CSP-registry enrichment; absent on agent builds before 2026-08-17.
string? msiDownloadUrl = null, msiMatchedBy = null;
if (imeVersionEvent.Data.TryGetValue("msiDownloadUrl", out var urlObj))
msiDownloadUrl = urlObj?.ToString();
if (imeVersionEvent.Data.TryGetValue("msiMatchedBy", out var matchedByObj))
msiMatchedBy = matchedByObj?.ToString();
_ = _sessionRepo.RecordImeVersionAsync(imeVersion, request.TenantId, request.SessionId)
.ContinueWith(async t =>
{
@@ -141,6 +152,19 @@ namespace AutopilotMonitor.Functions.Services
{
await _opsEventService.RecordNewImeVersionDetectedAsync(
imeVersion, request.TenantId, request.SessionId);
// First fleet-wide sighting → archive the installer binary while
// Microsoft's versionless CDN still serves exactly this build.
// Producer is fail-soft; worker pauses on ImeMsiArchivingEnabled=false.
await _imeMsiArchiveProducer.EnqueueAsync(new ImeMsiArchiveEnvelope
{
Version = imeVersion,
MsiDownloadUrl = msiDownloadUrl,
MsiMatchedBy = msiMatchedBy,
TenantId = request.TenantId,
SessionId = request.SessionId,
EnqueuedAt = DateTime.UtcNow,
});
}
}, TaskScheduler.Default);
}
@@ -0,0 +1,84 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Azure.Storage.Queues;
using AutopilotMonitor.Functions.Services.Queueing;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Azure Storage Queue implementation of <see cref="IImeMsiArchiveProducer"/>. Mirrors
/// <see cref="Analyze.AzureQueueAnalyzeOnEnrollmentEndProducer"/>: Managed Identity
/// preferred, connection-string fallback, Base64 encoding (matches the consumer worker).
/// <para>
/// <b>Fault tolerance:</b> queue-side exceptions are logged but never rethrown — the
/// caller is the ingest path's fire-and-forget continuation. A missed enqueue means the
/// version's installer is not auto-archived; the /ime-decompile skill's blob backfill
/// is the manual fallback.
/// </para>
/// </summary>
public sealed class AzureQueueImeMsiArchiveProducer : IImeMsiArchiveProducer
{
private readonly QueueClient _queueClient;
private readonly ILogger<AzureQueueImeMsiArchiveProducer> _logger;
private int _queueEnsured; // 0 = not yet ensured, 1 = CreateIfNotExistsAsync has run
public AzureQueueImeMsiArchiveProducer(
QueueClientFactory queueFactory,
ILogger<AzureQueueImeMsiArchiveProducer> logger)
{
_logger = logger;
// Base64 encoding matches the consumer (BackgroundService) so messages round-trip.
_queueClient = queueFactory.Create(Constants.QueueNames.ImeMsiArchive);
}
public async Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
if (envelope is null) return;
if (string.IsNullOrEmpty(envelope.Version))
{
_logger.LogWarning("ImeMsiArchive enqueue skipped — missing Version");
return;
}
await EnsureQueueExistsAsync(cancellationToken).ConfigureAwait(false);
try
{
var body = JsonConvert.SerializeObject(envelope);
await _queueClient.SendMessageAsync(body, cancellationToken: cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"ImeMsiArchive enqueued (version={Version} urlPresent={UrlPresent} tenant={Tenant} session={Session})",
envelope.Version, !string.IsNullOrEmpty(envelope.MsiDownloadUrl), envelope.TenantId, envelope.SessionId);
}
catch (Exception ex)
{
// Fail-soft — ingest continuation. Skill-side blob backfill is the fallback.
_logger.LogWarning(ex,
"ImeMsiArchive enqueue failed (version={Version}) — /ime-decompile blob backfill is the fallback",
envelope.Version);
}
}
private async Task EnsureQueueExistsAsync(CancellationToken cancellationToken)
{
if (_queueEnsured == 1) return;
try
{
await _queueClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
Interlocked.Exchange(ref _queueEnsured, 1);
}
catch (Exception ex)
{
// Leave _queueEnsured=0 so a later call retries.
_logger.LogWarning(ex, "ImeMsiArchive queue CreateIfNotExists failed — will retry next enqueue");
}
}
}
}
@@ -0,0 +1,16 @@
using System.Threading;
using System.Threading.Tasks;
using AutopilotMonitor.Shared.Models;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Enqueues an IME-installer archive job when a new IME version is first sighted.
/// Implementations must be fail-soft: the caller sits on the ingest hot path's
/// fire-and-forget continuation and must never observe an exception.
/// </summary>
public interface IImeMsiArchiveProducer
{
Task EnqueueAsync(ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default);
}
}
@@ -0,0 +1,87 @@
using AutopilotMonitor.Functions.Services.Queueing;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.DataAccess;
using AutopilotMonitor.Shared.Models;
using Microsoft.Extensions.Logging;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Background worker for the <c>ime-msi-archive</c> queue: downloads a newly sighted IME
/// installer into the permanent <c>ime-archive</c> container and merges the outcome onto
/// the ImeVersionHistory row. Volume is ~one message per Microsoft IME release.
/// <para>
/// Retry semantics: the archiver never throws — it classifies. Permanent rejections
/// (bad version, size cap) complete the message with their <c>Failed:*</c> status
/// persisted; transient failures (download/timeout) rethrow so the visibility-timeout
/// retry → poison ladder applies, with the last status visible on the row.
/// <see cref="UseHeartbeat"/> keeps the message invisible across the up-to-10-minute
/// download window. The status row-merge happens on EVERY attempt so operators can see
/// a stuck download as <c>Failed:Download</c> instead of nothing.
/// </para>
/// </summary>
public sealed class ImeMsiArchiveQueueWorker : QueuePollingWorker<ImeMsiArchiveEnvelope>
{
private readonly ImeMsiArchiver _archiver;
private readonly ISessionRepository _sessionRepo;
private readonly AdminConfigurationService _adminConfigService;
public ImeMsiArchiveQueueWorker(
QueueClientFactory queueFactory,
ImeMsiArchiver archiver,
ISessionRepository sessionRepo,
AdminConfigurationService adminConfigService,
ILogger<ImeMsiArchiveQueueWorker> logger)
: base(queueFactory, Constants.QueueNames.ImeMsiArchive, logger)
{
_archiver = archiver ?? throw new ArgumentNullException(nameof(archiver));
_sessionRepo = sessionRepo ?? throw new ArgumentNullException(nameof(sessionRepo));
_adminConfigService = adminConfigService ?? throw new ArgumentNullException(nameof(adminConfigService));
}
/// <summary>Download can legitimately take minutes — extend visibility while working.</summary>
protected override bool UseHeartbeat => true;
/// <summary>
/// Paused while <see cref="AdminConfiguration.ImeMsiArchivingEnabled"/> is off —
/// messages stay parked (not dropped) and are processed when the flag returns.
/// Uses the cached config read: this is a pause, not a kill-switch, and the worker
/// only wakes for rare messages anyway.
/// </summary>
protected override async ValueTask<bool> ShouldPauseAsync(CancellationToken ct)
{
try
{
var config = await _adminConfigService.GetConfigurationAsync();
return !config.ImeMsiArchivingEnabled;
}
catch
{
return false; // config unreadable → keep working (archive is fail-soft anyway)
}
}
protected override bool TryValidate(ImeMsiArchiveEnvelope envelope)
=> !string.IsNullOrEmpty(envelope.Version);
protected override string DescribeForLog(ImeMsiArchiveEnvelope envelope)
=> $"version {envelope.Version}";
protected override async Task HandleAsync(ImeMsiArchiveEnvelope envelope, CancellationToken ct)
{
var result = await _archiver.ArchiveAsync(envelope, ct);
// Merge the outcome first — also for failures, so the row always tells the truth.
await _sessionRepo.UpdateImeVersionArchiveInfoAsync(
envelope.Version, result.Status, result.BlobPath, result.Sha256, result.SizeBytes, result.SourceUrl);
if (!result.Success && result.Retryable)
{
// Visibility-timeout retry → poison after MaxDequeueCount. The thrown message
// lands in the worker's handler-failure log line.
throw new InvalidOperationException(
$"IME MSI archive attempt failed with {result.Status} for version {envelope.Version}");
}
}
}
}
@@ -0,0 +1,375 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using AutopilotMonitor.Shared;
using AutopilotMonitor.Shared.Models;
using Azure;
using Azure.Storage.Blobs.Models;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace AutopilotMonitor.Functions.Services.Ime
{
/// <summary>
/// Outcome of an IME-installer archive attempt. <see cref="Status"/> is one of the
/// <see cref="ImeMsiArchiver.Statuses"/> constants and is merged verbatim onto the
/// ImeVersionHistory row. <see cref="Retryable"/> tells the queue worker whether to
/// trigger a visibility-timeout retry (transient download problems) or complete the
/// message (permanent rejections).
/// </summary>
public sealed record ImeMsiArchiveResult(
bool Success, string Status, bool Retryable,
string? BlobPath, string? Sha256, long? SizeBytes, string? SourceUrl);
/// <summary>
/// Downloads a newly sighted IME installer and archives it into the permanent
/// <see cref="Constants.BlobContainers.ImeArchive"/> container
/// (<c>{version}/IntuneWindowsAgent.msi</c> + <c>{version}/provenance.json</c>).
/// Modeled on <see cref="Diagnostics.SessionReportDiagnosticsArchiveCopier"/>:
/// fail-soft by contract (never throws), Statuses constants persisted verbatim,
/// admin-configured size cap, typed catch ladder, <c>protected virtual</c> test seams.
/// <para>
/// The download URL comes from agent telemetry and is therefore untrusted: only HTTPS
/// URLs on <c>*.manage.microsoft.com</c> whose path filename is exactly
/// <c>IntuneWindowsAgent.msi</c> are accepted; anything else falls back to the canonical
/// versionless CDN URL — safe for a NEW version, because "new" is by definition what
/// that URL currently serves. The blob write uses If-None-Match:* so a queue re-delivery
/// can never overwrite an existing archive entry.
/// </para>
/// </summary>
public class ImeMsiArchiver
{
/// <summary>Persisted MsiArchiveStatus values.</summary>
public static class Statuses
{
public const string Archived = "Archived";
public const string FailedBadVersion = "Failed:BadVersion";
public const string FailedTooLarge = "Failed:TooLarge";
public const string FailedDownload = "Failed:Download";
public const string FailedTimeout = "Failed:Timeout";
public const string FailedError = "Failed:Error";
}
/// <summary>
/// The CSP's versionless distribution endpoint — always serves the currently rolled-out
/// build. Fallback when the event carried no (acceptable) URL.
/// </summary>
public const string CanonicalMsiUrl = "https://imeswdb-afd-secondary.manage.microsoft.com/IntuneWindowsAgent.msi";
private const string MsiFileName = "IntuneWindowsAgent.msi";
private const string AllowedHostSuffix = ".manage.microsoft.com";
/// <summary>Hard ceiling for one download attempt; the worker's heartbeat covers it.</summary>
private static readonly TimeSpan DownloadTimeout = TimeSpan.FromMinutes(10);
/// <summary>Blob folder name = version — must be plain dotted digits (untrusted input).</summary>
internal static readonly Regex VersionRegex = new(@"^\d+(\.\d+){1,3}$", RegexOptions.Compiled);
private readonly HttpClient _httpClient;
private readonly BlobStorageService _blobStorage;
private readonly AdminConfigurationService _adminConfigService;
private readonly ILogger<ImeMsiArchiver> _logger;
public ImeMsiArchiver(
HttpClient httpClient,
BlobStorageService blobStorage,
AdminConfigurationService adminConfigService,
ILogger<ImeMsiArchiver> logger)
{
_httpClient = httpClient;
_blobStorage = blobStorage;
_adminConfigService = adminConfigService;
_logger = logger;
}
/// <summary>
/// Test seam: Azure-free construction for recording subclasses that override the
/// virtual download/upload/config seams below.
/// </summary>
protected ImeMsiArchiver()
{
_httpClient = null!;
_blobStorage = null!;
_adminConfigService = null!;
_logger = NullLogger<ImeMsiArchiver>.Instance;
}
/// <summary>
/// True when <paramref name="url"/> is an HTTPS URL on <c>*.manage.microsoft.com</c>
/// whose path filename is exactly <c>IntuneWindowsAgent.msi</c> (query ignored).
/// </summary>
public static bool IsAllowedMsiUrl(string? url)
{
if (string.IsNullOrEmpty(url)) return false;
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) return false;
if (uri.Scheme != Uri.UriSchemeHttps) return false;
var host = uri.Host;
var hostAllowed =
host.EndsWith(AllowedHostSuffix, StringComparison.OrdinalIgnoreCase) ||
host.Equals(AllowedHostSuffix.TrimStart('.'), StringComparison.OrdinalIgnoreCase);
if (!hostAllowed) return false;
var fileName = uri.Segments.Length > 0 ? uri.Segments[^1] : string.Empty;
return fileName.Equals(MsiFileName, StringComparison.OrdinalIgnoreCase);
}
public virtual async Task<ImeMsiArchiveResult> ArchiveAsync(
ImeMsiArchiveEnvelope envelope, CancellationToken cancellationToken = default)
{
if (envelope is null || !VersionRegex.IsMatch(envelope.Version ?? string.Empty))
{
_logger.LogWarning(
"ImeMsiArchiver: rejecting version {Version} — not a plain dotted version string",
envelope?.Version ?? "(null)");
return new ImeMsiArchiveResult(false, Statuses.FailedBadVersion, Retryable: false,
null, null, null, null);
}
var version = envelope.Version!;
var blobPath = $"{version}/{MsiFileName}";
string sourceUrl;
if (IsAllowedMsiUrl(envelope.MsiDownloadUrl))
{
sourceUrl = envelope.MsiDownloadUrl!;
}
else
{
if (!string.IsNullOrEmpty(envelope.MsiDownloadUrl))
{
_logger.LogWarning(
"ImeMsiArchiver: event URL rejected by allowlist for version {Version} — using canonical URL",
version);
}
sourceUrl = CanonicalMsiUrl;
}
try
{
var adminConfig = await GetAdminConfigurationAsync();
var maxSizeBytes = (long)adminConfig.MaxImeMsiDownloadSizeMB * 1024 * 1024;
using var cts = new CancellationTokenSource(DownloadTimeout);
using var linked = CancellationTokenSource.CreateLinkedTokenSource(cts.Token, cancellationToken);
var (content, contentLength) = await OpenDownloadAsync(sourceUrl, linked.Token);
string sha256;
long totalBytes;
using (content)
{
// Same fast-reject as the diagnostics proxy: cap of 0 means unlimited.
if (maxSizeBytes > 0 && contentLength > maxSizeBytes)
{
_logger.LogWarning(
"ImeMsiArchiver: download for {Version} rejected — Content-Length {SizeBytes} exceeds limit {MaxSizeBytes}",
version, contentLength, maxSizeBytes);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
null, null, contentLength, sourceUrl);
}
// Hash + count while streaming into the blob; the wrapper enforces the cap
// mid-stream too (a lying/absent Content-Length cannot bypass it).
using var guarded = new HashingCappedReadStream(content, maxSizeBytes);
try
{
await UploadMsiAsync(blobPath, guarded, linked.Token);
}
catch (RequestFailedException ex) when (ex.Status == 409)
{
// Queue re-delivery after a successful upload: the archive already has
// this version — that IS the desired end state.
_logger.LogInformation(
"ImeMsiArchiver: blob {BlobPath} already exists — treating re-delivery as archived",
blobPath);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, null, null, sourceUrl);
}
sha256 = guarded.Sha256Hex;
totalBytes = guarded.TotalBytes;
}
var provenance = new
{
version,
url = sourceUrl,
urlFromEvent = sourceUrl != CanonicalMsiUrl,
msiMatchedBy = envelope.MsiMatchedBy,
sha256,
msiBytes = totalBytes,
firstSeenTenantId = envelope.TenantId,
firstSeenSessionId = envelope.SessionId,
archivedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ"),
};
await UploadProvenanceAsync(
$"{version}/provenance.json",
System.Text.Json.JsonSerializer.Serialize(provenance,
new System.Text.Json.JsonSerializerOptions { WriteIndented = true }),
linked.Token);
_logger.LogInformation(
"ImeMsiArchiver: archived {Version} ({SizeBytes} bytes, sha256 {Sha256}) from {SourceUrl}",
version, totalBytes, sha256, sourceUrl);
return new ImeMsiArchiveResult(true, Statuses.Archived, Retryable: false,
blobPath, sha256, totalBytes, sourceUrl);
}
catch (SizeCapExceededException)
{
_logger.LogWarning(
"ImeMsiArchiver: download for {Version} exceeded the size cap mid-stream — aborted",
version);
return new ImeMsiArchiveResult(false, Statuses.FailedTooLarge, Retryable: false,
null, null, null, sourceUrl);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
_logger.LogWarning("ImeMsiArchiver: download for {Version} timed out", version);
return new ImeMsiArchiveResult(false, Statuses.FailedTimeout, Retryable: true,
null, null, null, sourceUrl);
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "ImeMsiArchiver: download for {Version} failed", version);
return new ImeMsiArchiveResult(false, Statuses.FailedDownload, Retryable: true,
null, null, null, sourceUrl);
}
catch (OperationCanceledException)
{
// Host shutdown — let the worker's dispatch loop see it and leave the message.
throw;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "ImeMsiArchiver: archiving {Version} failed", version);
return new ImeMsiArchiveResult(false, Statuses.FailedError, Retryable: true,
null, null, null, sourceUrl);
}
}
// -------- Test seams (production defaults hit Azure / the network) --------
/// <summary>Fetches the platform admin configuration (size cap).</summary>
protected virtual Task<AdminConfiguration> GetAdminConfigurationAsync()
=> _adminConfigService.GetConfigurationAsync();
/// <summary>Opens the installer download stream (headers-read, no buffering).</summary>
protected virtual async Task<(Stream Content, long ContentLength)> OpenDownloadAsync(
string url, CancellationToken cancellationToken)
{
var response = await _httpClient.GetAsync(
url, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStreamAsync(cancellationToken);
return (content, response.Content.Headers.ContentLength ?? -1);
}
/// <summary>
/// Uploads the installer stream write-once (If-None-Match:*). Implementations must
/// surface a conflict as <see cref="RequestFailedException"/> with status 409.
/// </summary>
protected virtual async Task UploadMsiAsync(string blobPath, Stream content, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
await containerClient.CreateIfNotExistsAsync(cancellationToken: cancellationToken);
var blobClient = containerClient.GetBlobClient(blobPath);
await blobClient.UploadAsync(content, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/octet-stream" },
Conditions = new BlobRequestConditions { IfNoneMatch = ETag.All }
}, cancellationToken);
}
/// <summary>Uploads the provenance sidecar (overwrite allowed — it is derived data).</summary>
protected virtual async Task UploadProvenanceAsync(string blobPath, string json, CancellationToken cancellationToken)
{
var containerClient = _blobStorage.GetContainerClient(Constants.BlobContainers.ImeArchive);
var blobClient = containerClient.GetBlobClient(blobPath);
using var stream = new MemoryStream(Encoding.UTF8.GetBytes(json));
await blobClient.UploadAsync(stream, new BlobUploadOptions
{
HttpHeaders = new BlobHttpHeaders { ContentType = "application/json" }
}, cancellationToken);
}
// -------- Streaming helpers --------
private sealed class SizeCapExceededException : Exception
{
}
/// <summary>
/// Read-only pass-through stream that SHA-256-hashes and counts everything read and
/// throws once the byte count exceeds the cap (0 = unlimited). Non-seekable on
/// purpose — the blob SDK then stages blocks instead of demanding Length upfront.
/// </summary>
private sealed class HashingCappedReadStream : Stream
{
private readonly Stream _inner;
private readonly long _maxBytes;
private readonly IncrementalHash _hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
private string? _sha256Hex;
public HashingCappedReadStream(Stream inner, long maxBytes)
{
_inner = inner;
_maxBytes = maxBytes;
}
public long TotalBytes { get; private set; }
public string Sha256Hex => _sha256Hex ??=
Convert.ToHexString(_hash.GetHashAndReset()).ToLowerInvariant();
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => TotalBytes;
set => throw new NotSupportedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = _inner.Read(buffer, offset, count);
Account(buffer.AsSpan(offset, read));
return read;
}
public override async ValueTask<int> ReadAsync(Memory<byte> buffer, CancellationToken cancellationToken = default)
{
var read = await _inner.ReadAsync(buffer, cancellationToken);
Account(buffer.Span[..read]);
return read;
}
public override Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
=> ReadAsync(buffer.AsMemory(offset, count), cancellationToken).AsTask();
private void Account(ReadOnlySpan<byte> data)
{
if (data.IsEmpty) return;
TotalBytes += data.Length;
if (_maxBytes > 0 && TotalBytes > _maxBytes)
throw new SizeCapExceededException();
_hash.AppendData(data);
}
public override void Flush()
{
}
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
if (disposing) _hash.Dispose();
base.Dispose(disposing);
}
}
}
}
@@ -199,7 +199,9 @@ namespace AutopilotMonitor.Functions.Services
if (existingLookup.TryGetValue(pattern.PatternId, out var existing))
{
if (existing.Pattern != pattern.Pattern || existing.Action != pattern.Action
|| existing.Category != pattern.Category || existing.Description != pattern.Description)
|| existing.Category != pattern.Category || existing.Description != pattern.Description
|| existing.Enabled != pattern.Enabled
|| !ParametersEqual(existing.Parameters, pattern.Parameters))
{
await _ruleRepo.StoreImeLogPatternAsync(pattern, "global");
updated++;
@@ -220,5 +222,18 @@ namespace AutopilotMonitor.Functions.Services
_seeded = true;
}
private static bool ParametersEqual(Dictionary<string, string>? a, Dictionary<string, string>? b)
{
if ((a?.Count ?? 0) != (b?.Count ?? 0)) return false;
if (a == null || b == null) return true;
foreach (var kv in a)
{
if (!b.TryGetValue(kv.Key, out var value) || value != kv.Value) return false;
}
return true;
}
}
}
@@ -3316,7 +3316,12 @@ namespace AutopilotMonitor.Functions.Services
FirstSeenSessionId = entity.GetString("FirstSeenSessionId") ?? string.Empty,
FirstSeenTenantId = entity.GetString("FirstSeenTenantId") ?? string.Empty,
LastSeenAt = entity.GetDateTime("LastSeenAt") ?? DateTime.MinValue,
SessionCount = entity.GetInt32("SessionCount") ?? 0
SessionCount = entity.GetInt32("SessionCount") ?? 0,
MsiArchiveStatus = entity.GetString("MsiArchiveStatus"),
MsiArchiveBlobPath = entity.GetString("MsiArchiveBlobPath"),
MsiSha256 = entity.GetString("MsiSha256"),
MsiBytes = entity.GetInt64("MsiBytes"),
MsiSourceUrl = entity.GetString("MsiSourceUrl")
});
}
}
@@ -3328,6 +3333,38 @@ namespace AutopilotMonitor.Functions.Services
return results.OrderByDescending(e => e.FirstSeenAt).ToList();
}
/// <summary>
/// Merges the installer-archiving outcome into the version's ImeVersionHistory row
/// (same Merge idiom as the LastSeen update above). Fail-soft: the archive job
/// already logged its own result; a lost status column must never fail the worker.
/// </summary>
public async Task UpdateImeVersionArchiveInfoAsync(
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl)
{
if (string.IsNullOrEmpty(version) || string.IsNullOrEmpty(status)) return;
try
{
var tableClient = _tableServiceClient.GetTableClient(Constants.TableNames.ImeVersionHistory);
var mergeEntity = new TableEntity("Global", version)
{
["MsiArchiveStatus"] = status
};
if (!string.IsNullOrEmpty(blobPath)) mergeEntity["MsiArchiveBlobPath"] = blobPath;
if (!string.IsNullOrEmpty(sha256)) mergeEntity["MsiSha256"] = sha256;
if (sizeBytes.HasValue) mergeEntity["MsiBytes"] = sizeBytes.Value;
if (!string.IsNullOrEmpty(sourceUrl)) mergeEntity["MsiSourceUrl"] = sourceUrl;
await tableClient.UpsertEntityAsync(mergeEntity, TableUpdateMode.Merge);
}
catch (Exception ex)
{
_logger.LogWarning(ex,
"Failed to update ImeVersionHistory archive info for version {Version} (status {Status})",
version, status);
}
}
#endregion
#region Excessive-Event Detection
@@ -146,6 +146,9 @@ export const EVENT_TYPES_CATALOG = {
'agent_shutdown',
'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',
@@ -416,6 +416,12 @@ namespace AutopilotMonitor.Shared
// IME log tracker — app / device / script telemetry (V2 single-rail plan §5.9)
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";
@@ -1295,6 +1301,19 @@ namespace AutopilotMonitor.Shared
/// settings (Soft-Delete + Versioning) provide defence-in-depth.
/// </summary>
public const string CriticalTableBackups = "critical-table-backups";
/// <summary>
/// Permanent platform archive of IME (Intune Management Extension) installer
/// binaries, one virtual folder per version:
/// <c>{version}/IntuneWindowsAgent.msi</c> + <c>{version}/provenance.json</c>.
/// Written by <c>ImeMsiArchiver</c> when a new IME version is first seen across
/// the fleet (companion of <see cref="TableNames.ImeVersionHistory"/>, which is
/// equally KeptByDesign). NOT tenant-scoped — blob paths are version-keyed, so
/// tenant offboarding (Phase 2.E) deliberately never touches this container.
/// No lifecycle rule: the archive is the point (build-to-build diffs of
/// Microsoft's rollouts). Private by default (SDK CreateIfNotExists).
/// </summary>
public const string ImeArchive = "ime-archive";
}
/// <summary>
@@ -1392,6 +1411,18 @@ namespace AutopilotMonitor.Shared
/// <summary>Poison sibling of <see cref="SessionDeletionMaintenance"/>.</summary>
public const string SessionDeletionMaintenancePoison = "session-deletion-maintenance-poison";
/// <summary>
/// IME-MSI archive fan-out. Producer = <c>EventIngestProcessor</c> when
/// <c>RecordImeVersionAsync</c> reports a genuinely NEW IME version (first sighting
/// across the fleet — roughly monthly); consumer = <c>ImeMsiArchiveQueueWorker</c>,
/// which downloads the installer from the CSP-reported
/// <c>msiDownloadUrl</c> (host-allowlisted, size-capped) into
/// <see cref="BlobContainers.ImeArchive"/> and merges the outcome into the
/// <see cref="TableNames.ImeVersionHistory"/> row. Poison suffix <c>-poison</c>,
/// max-dequeue 5.
/// </summary>
public const string ImeMsiArchive = "ime-msi-archive";
}
/// <summary>
@@ -176,6 +176,14 @@ namespace AutopilotMonitor.Shared.DataAccess
Task<bool> RecordImeVersionAsync(string version, string tenantId, string sessionId);
Task<List<ImeVersionHistoryEntry>> GetImeVersionHistoryAsync();
/// <summary>
/// Merges the installer-archiving outcome (<c>ImeMsiArchiver</c>) into the version's
/// <c>ImeVersionHistory</c> row. Fail-soft: storage errors are logged, never thrown.
/// Blob path / hash / size / URL are only written on success (null leaves them absent).
/// </summary>
Task UpdateImeVersionArchiveInfoAsync(
string version, string status, string? blobPath, string? sha256, long? sizeBytes, string? sourceUrl);
// --- Events ---
Task<bool> StoreEventAsync(EnrollmentEvent evt);
Task<List<EnrollmentEvent>> StoreEventsBatchAsync(List<EnrollmentEvent> events);
@@ -460,6 +460,24 @@ namespace AutopilotMonitor.Shared.Models
/// </summary>
public bool AutoApproveNewTenants { get; set; } = false;
/// <summary>
/// When true, the first fleet-wide sighting of a new IME version enqueues an
/// archive job (<c>ime-msi-archive</c> queue) that downloads the installer from the
/// CSP-reported URL into the <c>ime-archive</c> blob container. Roughly one download
/// per Microsoft IME release (~monthly). Default true — flip off to pause archiving
/// (queued messages then wait, they are not dropped). Round-tripped via the 4-file
/// web chain (memory <c>feedback_admin_config_ui_roundtrip</c>).
/// </summary>
public bool ImeMsiArchivingEnabled { get; set; } = true;
/// <summary>
/// Size cap for the IME installer download in MB (Content-Length preflight AND
/// streamed-byte guard). The MSI is ~12 MB today; the generous cap exists only so a
/// tampered/wrong URL cannot stream gigabytes into the archive while still never
/// missing a legitimately grown installer. 0 = no limit. Default: 250 MB.
/// </summary>
public int MaxImeMsiDownloadSizeMB { get; set; } = 250;
/// <summary>
/// When true, the dual app-registration self-service homing flip is active: the Graph
/// consent flow funnels legacy-homed tenants to the primary (new) app registration and
@@ -0,0 +1,44 @@
using System;
namespace AutopilotMonitor.Shared.Models
{
/// <summary>
/// Queue-message envelope for the <c>ime-msi-archive</c> queue. Enqueued by
/// <c>EventIngestProcessor</c> when an <c>ime_agent_version</c> event carries a version
/// the platform has never seen before (RecordImeVersionAsync insert succeeded). The
/// worker downloads the IME installer and archives it into the <c>ime-archive</c> blob
/// container so every fleet-observed IME build stays available for later decompilation
/// and build-to-build diffing, even after Microsoft's versionless CDN URL has moved on.
/// </summary>
public sealed class ImeMsiArchiveEnvelope
{
/// <summary>Schema version — bump on breaking envelope changes so consumers can reject or migrate.</summary>
public string EnvelopeVersion { get; set; } = "1";
/// <summary>The newly sighted IME version, e.g. <c>1.104.102.0</c> (blob folder name).</summary>
public string Version { get; set; } = string.Empty;
/// <summary>
/// The EnterpriseDesktopAppManagement-CSP <c>CurrentDownloadUrl</c> from the event's
/// <c>msiDownloadUrl</c> enrichment. Null when the reporting agent predates the
/// enrichment — the worker then falls back to the canonical versionless CDN URL
/// (safe: a brand-new version is by definition what that URL currently serves).
/// </summary>
public string? MsiDownloadUrl { get; set; }
/// <summary>
/// The event's <c>msiMatchedBy</c> companion (<c>productVersion</c> = URL is
/// version-authoritative, <c>fileName</c> = registry drift). Provenance only.
/// </summary>
public string? MsiMatchedBy { get; set; }
/// <summary>First-seen tenant — provenance only, never used for auth or paths.</summary>
public string TenantId { get; set; } = string.Empty;
/// <summary>First-seen session — provenance only.</summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>UTC time the producer enqueued the message; useful for measuring queue lag.</summary>
public DateTime EnqueuedAt { get; set; }
}
}
@@ -575,5 +575,25 @@ namespace AutopilotMonitor.Shared.Models
public string FirstSeenTenantId { get; set; } = string.Empty;
public DateTime LastSeenAt { get; set; }
public int SessionCount { get; set; }
/// <summary>
/// Outcome of the automatic installer archiving (<c>ImeMsiArchiver</c>):
/// <c>Archived</c> or a <c>Failed:*</c> status; null for versions sighted before the
/// feature existed or while the archive job is still queued. Like the FirstSeen*
/// identifiers, the archive fields are only serialized for Global Admin callers.
/// </summary>
public string? MsiArchiveStatus { get; set; }
/// <summary>Blob path inside the <c>ime-archive</c> container, e.g. <c>1.104.102.0/IntuneWindowsAgent.msi</c>.</summary>
public string? MsiArchiveBlobPath { get; set; }
/// <summary>SHA-256 of the archived installer (hex, lowercase).</summary>
public string? MsiSha256 { get; set; }
/// <summary>Size of the archived installer in bytes.</summary>
public long? MsiBytes { get; set; }
/// <summary>The URL the installer was downloaded from (CSP-reported or canonical fallback).</summary>
public string? MsiSourceUrl { get; set; }
}
}
@@ -50,6 +50,10 @@ interface AdminConfigContextValue {
setAutoApproveNewTenants: (value: boolean) => void;
selfServiceAppHomingEnabled: boolean;
setSelfServiceAppHomingEnabled: (value: boolean) => void;
imeMsiArchivingEnabled: boolean;
setImeMsiArchivingEnabled: (value: boolean) => void;
maxImeMsiDownloadSizeMB: number;
setMaxImeMsiDownloadSizeMB: (value: number) => void;
// Diagnostics log paths
globalDiagPaths: DiagnosticsLogPath[];
@@ -152,6 +156,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
const [sessionDeletionKillSwitch, setSessionDeletionKillSwitch] = useState(false);
const [autoApproveNewTenants, setAutoApproveNewTenants] = useState(false);
const [selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled] = useState(false);
const [imeMsiArchivingEnabled, setImeMsiArchivingEnabled] = useState(true);
const [maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB] = useState(250);
// Diagnostics Log Paths state
const [globalDiagPaths, setGlobalDiagPaths] = useState<DiagnosticsLogPath[]>([]);
@@ -215,6 +221,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setSessionDeletionKillSwitch(data.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(data.autoApproveNewTenants ?? false);
setSelfServiceAppHomingEnabled(data.selfServiceAppHomingEnabled ?? false);
setImeMsiArchivingEnabled(data.imeMsiArchivingEnabled ?? true);
setMaxImeMsiDownloadSizeMB(data.maxImeMsiDownloadSizeMB ?? 250);
try {
setGlobalDiagPaths(data.diagnosticsGlobalLogPathsJson ? JSON.parse(data.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -332,6 +340,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
sessionDeletionKillSwitch,
autoApproveNewTenants,
selfServiceAppHomingEnabled,
imeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB,
};
const response = await authenticatedFetch(api.globalConfig.get(), getAccessToken, {
@@ -358,7 +368,7 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
} finally {
setSavingConfig(false);
}
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, getAccessToken]);
}, [isGlobalAdmin, adminConfig, globalRateLimit, userRateLimit, globalAdminRateLimit, platformStatsBlobSasUrl, agentMigrateApiBaseUrl, agentMigrateTenantOverridesJson, collectorIdleTimeoutMinutes, desktopDetectorNoCandidateTimeoutMinutes, opsEventRetentionDays, slaNotificationCooldownHours, allowAgentDowngrade, modernDeploymentHarmlessEventIds, enableIndexDualWrite, sessionDeletionKillSwitch, autoApproveNewTenants, selfServiceAppHomingEnabled, imeMsiArchivingEnabled, maxImeMsiDownloadSizeMB, getAccessToken]);
// Reset admin config
const handleResetAdminConfig = useCallback(() => {
@@ -379,6 +389,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
setSessionDeletionKillSwitch(adminConfig.sessionDeletionKillSwitch ?? false);
setAutoApproveNewTenants(adminConfig.autoApproveNewTenants ?? false);
setSelfServiceAppHomingEnabled(adminConfig.selfServiceAppHomingEnabled ?? false);
setImeMsiArchivingEnabled(adminConfig.imeMsiArchivingEnabled ?? true);
setMaxImeMsiDownloadSizeMB(adminConfig.maxImeMsiDownloadSizeMB ?? 250);
try {
setGlobalDiagPaths(adminConfig.diagnosticsGlobalLogPathsJson ? JSON.parse(adminConfig.diagnosticsGlobalLogPathsJson) : []);
} catch {
@@ -525,6 +537,8 @@ export function AdminConfigProvider({ children }: { children: React.ReactNode })
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
autoApproveNewTenants, setAutoApproveNewTenants,
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
globalDiagPaths, setGlobalDiagPaths, savingDiagPaths,
opsAlertRules, setOpsAlertRules,
opsAlertTelegramEnabled, setOpsAlertTelegramEnabled,
@@ -36,6 +36,10 @@ interface AdminConfigSettingsSectionProps {
setAutoApproveNewTenants: (value: boolean) => void;
selfServiceAppHomingEnabled: boolean;
setSelfServiceAppHomingEnabled: (value: boolean) => void;
imeMsiArchivingEnabled: boolean;
setImeMsiArchivingEnabled: (value: boolean) => void;
maxImeMsiDownloadSizeMB: number;
setMaxImeMsiDownloadSizeMB: (value: number) => void;
onSave: () => Promise<void>;
onReset: () => void;
}
@@ -74,6 +78,10 @@ export function AdminConfigSettingsSection({
setAutoApproveNewTenants,
selfServiceAppHomingEnabled,
setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled,
setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB,
setMaxImeMsiDownloadSizeMB,
onSave,
onReset,
}: AdminConfigSettingsSectionProps) {
@@ -375,6 +383,47 @@ export function AdminConfigSettingsSection({
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
type="checkbox"
checked={imeMsiArchivingEnabled}
onChange={(e) => setImeMsiArchivingEnabled(e.target.checked)}
className="mt-1 h-5 w-5 rounded border-indigo-300 dark:border-indigo-600 text-indigo-600 accent-indigo-600 focus:ring-indigo-500"
/>
<span>
<span className="text-indigo-900 dark:text-indigo-100 font-medium">IME installer archiving</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mt-1">
When <strong>on</strong>, the first fleet-wide sighting of a new IME version downloads
the installer (from the CSP-reported, allowlisted URL) into the permanent
<code className="ml-1 text-xs bg-indigo-100 dark:bg-indigo-900 dark:text-indigo-200 px-1 rounded">ime-archive</code> blob
container — roughly one small download per Microsoft IME release. The outcome is
visible per version in the IME version history. When <strong>off</strong>, queued
archive jobs wait (they are not dropped). Default <strong>on</strong>.
</p>
</span>
</label>
</div>
<div>
<label className="block">
<span className="text-indigo-900 dark:text-indigo-100 font-medium">IME installer download size cap (MB)</span>
<p className="text-sm text-indigo-800 dark:text-gray-300 mb-2">
Safety cap for the IME installer download (the MSI is ~12 MB today). Generous on
purpose so a legitimately grown installer is never missed, while a wrong URL can
still not stream gigabytes into the archive. 0 = unlimited. Default 250.
</p>
<input
type="number"
min="0"
max="10000"
value={maxImeMsiDownloadSizeMB}
onChange={(e) => setMaxImeMsiDownloadSizeMB(parseInt(e.target.value) || 0)}
className="mt-1 block w-full px-4 py-2 border border-indigo-300 dark:border-indigo-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-green-500 focus:border-green-500 transition-colors"
/>
</label>
</div>
<div>
<label className="flex items-start space-x-3 cursor-pointer">
<input
@@ -24,6 +24,8 @@ export function SectionGlobalSettings() {
sessionDeletionKillSwitch, setSessionDeletionKillSwitch,
autoApproveNewTenants, setAutoApproveNewTenants,
selfServiceAppHomingEnabled, setSelfServiceAppHomingEnabled,
imeMsiArchivingEnabled, setImeMsiArchivingEnabled,
maxImeMsiDownloadSizeMB, setMaxImeMsiDownloadSizeMB,
handleSaveAdminConfig, handleResetAdminConfig,
} = useAdminConfig();
@@ -66,6 +68,10 @@ export function SectionGlobalSettings() {
setAutoApproveNewTenants={setAutoApproveNewTenants}
selfServiceAppHomingEnabled={selfServiceAppHomingEnabled}
setSelfServiceAppHomingEnabled={setSelfServiceAppHomingEnabled}
imeMsiArchivingEnabled={imeMsiArchivingEnabled}
setImeMsiArchivingEnabled={setImeMsiArchivingEnabled}
maxImeMsiDownloadSizeMB={maxImeMsiDownloadSizeMB}
setMaxImeMsiDownloadSizeMB={setMaxImeMsiDownloadSizeMB}
onSave={handleSaveAdminConfig}
onReset={handleResetAdminConfig}
/>
@@ -2,6 +2,7 @@
import { sessionUrl } from "@/lib/routes";
import { useRouter } from "next/navigation";
import Link from "next/link";
import type { Route } from "next";
import { useState, useEffect, useRef, useMemo, useDeferredValue } from "react";
import { Session } from "../types";
@@ -709,6 +710,7 @@ export function SessionTable({
key={col.key}
columnKey={col.key}
session={session}
href={linkFor(session)}
adminMode={adminMode}
globalAdminMode={globalAdminMode}
blockedDevicesSet={blockedDevicesSet}
@@ -782,9 +784,35 @@ export function SessionTable({
);
}
/** Invisible cell-filling anchor so the browser's native context menu (open in new
* tab, copy link) and middle/ctrl+click work on session rows. Visually nothing — the
* row keeps its exact current look; navigation UX is unchanged because plain clicks
* still do an SPA push via Link. Cells with their own interactive content or title
* tooltips (status badges, tenant copy button, actions) must NOT render this overlay
* as it would sit above them and swallow their hover/click. aria-hidden + tabIndex -1
* keep it out of tab order and screen readers — the row's click target is unchanged. */
function RowLinkOverlay({ href, session }: { href: Route; session: Session }) {
return (
<Link
href={href}
prefetch={false}
tabIndex={-1}
aria-hidden="true"
draggable={false}
className="absolute inset-0"
onClick={(e) => {
// Link handles the navigation itself; stop the row's onClick from double-pushing.
e.stopPropagation();
trackEvent("session_opened", { sessionId: session.sessionId, status: session.status ?? "" });
}}
/>
);
}
function SessionCell({
columnKey,
session,
href,
globalAdminMode,
blockedDevicesSet,
user,
@@ -795,6 +823,7 @@ function SessionCell({
}: {
columnKey: string;
session: Session;
href: Route;
adminMode: boolean;
globalAdminMode: boolean;
blockedDevicesSet: Set<string>;
@@ -808,7 +837,8 @@ function SessionCell({
switch (columnKey) {
case "device":
return (
<td className="px-6 py-4 whitespace-nowrap">
<td className="relative px-6 py-4 whitespace-nowrap">
<RowLinkOverlay href={href} session={session} />
<div className="text-sm font-medium text-gray-900">
{session.deviceName || session.serialNumber}
</div>
@@ -849,7 +879,8 @@ function SessionCell({
case "model":
return (
<td className="px-6 py-4 whitespace-nowrap">
<td className="relative px-6 py-4 whitespace-nowrap">
<RowLinkOverlay href={href} session={session} />
<div className="text-sm font-medium text-gray-900">
{session.manufacturer || "Unknown manufacturer"}
</div>
@@ -907,14 +938,16 @@ function SessionCell({
case "eventCount":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<RowLinkOverlay href={href} session={session} />
{session.eventCount}
</td>
);
case "duration":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<RowLinkOverlay href={href} session={session} />
{Math.round(session.durationSeconds / 60)} min
</td>
);
@@ -923,7 +956,8 @@ function SessionCell({
// Two-line date/time keeps the column narrow so more columns fit on one page.
const started = new Date(session.startedAt);
return (
<td className="px-3 py-4 whitespace-nowrap">
<td className="relative px-3 py-4 whitespace-nowrap">
<RowLinkOverlay href={href} session={session} />
<div className="text-sm font-medium text-gray-900">{started.toLocaleDateString()}</div>
<div className="text-sm text-gray-500">{started.toLocaleTimeString()}</div>
</td>
@@ -985,35 +1019,40 @@ function SessionCell({
case "osName":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<RowLinkOverlay href={href} session={session} />
{session.osName || <span className="text-gray-300">—</span>}
</td>
);
case "osBuild":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500 font-mono">
<RowLinkOverlay href={href} session={session} />
{session.osBuild || <span className="text-gray-300">—</span>}
</td>
);
case "osDisplayVersion":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<RowLinkOverlay href={href} session={session} />
{session.osDisplayVersion || <span className="text-gray-300">—</span>}
</td>
);
case "osEdition":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<RowLinkOverlay href={href} session={session} />
{session.osEdition || <span className="text-gray-300">—</span>}
</td>
);
case "osLanguage":
return (
<td className="px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<td className="relative px-3 py-4 whitespace-nowrap text-sm text-gray-500">
<RowLinkOverlay href={href} session={session} />
{session.osLanguage || <span className="text-gray-300">—</span>}
</td>
);
@@ -83,6 +83,8 @@ interface DownloadItem {
downloadRateBps: number;
lastUpdated: string;
lastUpdatedMs: number;
startedMs: number;
completedMs: number | null;
isComplete: boolean;
isSkipped: boolean;
firstSeenIndex: number;
@@ -118,6 +120,19 @@ function formatDoDuration(duration: string): string {
return formatDuration(Math.floor(ms / 1000));
}
// Per-app download duration. Prefers the DO-reported duration ([DO TEL] JSON,
// present up to IME ~1.97); IME 1.104 removed that log line entirely, so fall
// back to the observed event window: first download event -> first event that
// marked the download complete.
function effectiveDurationMs(dl: DownloadItem): number {
const doMs = dl.doStats?.downloadDuration ? parseDoDurationMs(dl.doStats.downloadDuration) : 0;
if (doMs > 0) return doMs;
if (dl.completedMs != null && Number.isFinite(dl.startedMs) && dl.completedMs > dl.startedMs) {
return dl.completedMs - dl.startedMs;
}
return 0;
}
export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) {
// Legacy-agent guard: drop download events replayed from a previous enrollment's IME log.
// Silent (empty finals set) — the InstallProgress panel already reports the hidden count
@@ -219,6 +234,8 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
// memo-recompute would be an arbitrary base for event-time deltas
// anyway (render must also stay pure — react-hooks/purity).
lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? NaN),
startedMs: existing?.startedMs ?? (Number.isFinite(eventTs) ? eventTs : NaN),
completedMs: existing?.completedMs ?? (isComplete && Number.isFinite(eventTs) ? eventTs : null),
isComplete,
isSkipped: isSkippedEvent || (existing?.isSkipped ?? false),
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
@@ -237,18 +254,17 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
const [expanded, setExpanded] = useState(true);
const [showSkipped, setShowSkipped] = useState(false);
// Sum of individual download durations (from DO telemetry per app)
// Sum of individual download durations (DO telemetry when present, otherwise
// the observed start->complete event window per app).
// Must be before the early return to keep hooks in stable order across renders.
const totalDuration = useMemo(() => {
let sum = 0;
let hasAny = false;
for (const dl of downloads) {
if (dl.doStats?.downloadDuration) {
const ms = parseDoDurationMs(dl.doStats.downloadDuration);
if (ms > 0) {
sum += ms;
hasAny = true;
}
const ms = effectiveDurationMs(dl);
if (ms > 0) {
sum += ms;
hasAny = true;
}
}
return hasAny ? sum : null;
@@ -365,6 +381,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
const [showDetails, setShowDetails] = useState(false);
const [showDoStats, setShowDoStats] = useState(false);
const hasKnownTotal = dl.bytesTotal > 0;
const durationMs = effectiveDurationMs(dl);
const showProgressBar = !dl.isSkipped && (hasKnownTotal || dl.isComplete || !dl.isComplete);
// Determine container styling
@@ -429,6 +446,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
: dl.isComplete
? "Completed"
: `${formatBytes(dl.bytesDownloaded)} downloaded`}
{dl.isComplete && durationMs > 0 && (
<span className="text-gray-400"> in {formatDuration(Math.floor(durationMs / 1000))}</span>
)}
</span>
<span>{progressPercent > 0 ? `${progressPercent.toFixed(0)}%` : "started"}</span>
</div>
@@ -479,7 +499,9 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs text-gray-600">
<div>
<span className="text-gray-500">Duration:</span>{" "}
<span className="font-medium">{formatDoDuration(dl.doStats.downloadDuration)}</span>
<span className="font-medium">
{durationMs > 0 ? formatDuration(Math.floor(durationMs / 1000)) : formatDoDuration(dl.doStats.downloadDuration)}
</span>
</div>
<div>
<span className="text-gray-500">File Size:</span>{" "}
@@ -99,6 +99,17 @@ export interface AdminConfiguration {
* only Global Admins can flip while it is off. Default false.
*/
selfServiceAppHomingEnabled?: boolean;
/**
* When true, the first fleet-wide sighting of a new IME version downloads the
* installer into the permanent ime-archive blob container (ime-msi-archive queue
* worker). When false, queued archive jobs wait — they are not dropped. Default true.
*/
imeMsiArchivingEnabled?: boolean;
/**
* Size cap for the IME installer download in MB (preflight + mid-stream guard).
* 0 = unlimited. Default 250.
*/
maxImeMsiDownloadSizeMB?: number;
}
export interface OpsAlertRule {
@@ -50,6 +50,8 @@ export const SHARED_MANIFEST = {
"vulnerabilityCorrelationEnabled",
"enableIndexDualWrite",
"autoApproveNewTenants",
"imeMsiArchivingEnabled",
"maxImeMsiDownloadSizeMB",
"selfServiceAppHomingEnabled",
"sessionDeletionKillSwitch",
"vulnerabilityDataLastSyncUtc",
@@ -486,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",
@@ -557,6 +560,7 @@ export const SHARED_MANIFEST = {
"ime_agent_version",
"ime_process_exited",
"ime_session_change",
"ime_token_failure",
"ime_user_session_completed",
"ingress_backpressure",
"integrity_bypass_analysis",
@@ -600,6 +604,7 @@ export const SHARED_MANIFEST = {
"realmjoin_resolved",
"realmjoin_timeout",
"reboot_triggered",
"registry_app_state",
"remote_config_fetch_failed",
"script_completed",
"script_failed",
@@ -46,6 +46,8 @@
"vulnerabilityCorrelationEnabled",
"enableIndexDualWrite",
"autoApproveNewTenants",
"imeMsiArchivingEnabled",
"maxImeMsiDownloadSizeMB",
"selfServiceAppHomingEnabled",
"sessionDeletionKillSwitch",
"vulnerabilityDataLastSyncUtc",
@@ -482,6 +484,7 @@
"app_install_skipped",
"app_install_started",
"app_install_starved",
"app_state_reconciliation",
"app_tracking_summary",
"autologon_analysis",
"autopilot_profile",
@@ -553,6 +556,7 @@
"ime_agent_version",
"ime_process_exited",
"ime_session_change",
"ime_token_failure",
"ime_user_session_completed",
"ingress_backpressure",
"integrity_bypass_analysis",
@@ -596,6 +600,7 @@
"realmjoin_resolved",
"realmjoin_timeout",
"reboot_triggered",
"registry_app_state",
"remote_config_fetch_failed",
"script_completed",
"script_failed",