Compare commits

...

11 Commits

Author SHA1 Message Date
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
49 changed files with 1638 additions and 168 deletions
+110 -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,17 @@
"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-HRESULT",
"category": "currentPhase",
@@ -377,25 +415,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 +460,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 +484,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 +527,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 +578,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 +589,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",
@@ -564,24 +624,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 +667,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
}
@@ -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
}
@@ -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
}
@@ -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);
}
}
}
@@ -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;
@@ -487,6 +488,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()
{
@@ -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; }
}
}
@@ -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}");
@@ -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;
@@ -97,6 +98,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.
@@ -864,6 +871,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 +894,17 @@ 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}");
}
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)
@@ -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>
@@ -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>
@@ -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),
@@ -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;
}
}
}
@@ -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>
);