Compare commits

...

7 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
23 changed files with 1204 additions and 68 deletions
+14 -14
View File
@@ -169,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
},
{
@@ -236,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\"\":(?:\"\"\\w+\"\"|null),\"\"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 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).",
"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
},
{
@@ -255,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
},
{
@@ -285,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+)\"\"|null),\"\"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. 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).",
"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
},
{
@@ -415,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
},
{
@@ -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,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\"\":(?:\"\"\\w+\"\"|null),\"\"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 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).",
"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+)\"\"|null),\"\"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. 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).",
"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
}
@@ -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
}
@@ -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;
}
@@ -534,14 +568,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
if (file.EndsWith(".evtx", StringComparison.OrdinalIgnoreCase))
{
tempExport = TryExportEventLogChannel(file);
if (tempExport == null)
if (tempExport != null)
{
tracker.RecordSkip(file, "evtx-export", length);
continue;
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}");
}
streamSource = tempExport;
try { length = new FileInfo(tempExport).Length; }
catch { /* keep original length estimate */ }
}
try
@@ -550,18 +591,21 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
_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;
}
@@ -580,6 +624,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
tracker.RecordIncluded(length);
_logger.Debug($"Added to diagnostics package: {entryName} ({length / 1024} KB)");
ManifestLine($"ADDED: {entryName} ({length} bytes)");
}
finally
{
@@ -592,23 +637,59 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
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>. Channel name is derived from the file name (%4 decodes to '/',
/// e.g. "Microsoft-Autopilot-BootstrapperAgent%4BootstrapperAgentServiceLogProvider.evtx"
/// → "Microsoft-Autopilot-BootstrapperAgent/BootstrapperAgentServiceLogProvider").
/// Returns the temp file path (caller deletes) or null when the export failed.
/// <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 = Path.GetFileNameWithoutExtension(evtxPath).Replace("%4", "/");
var channel = ResolveChannelForEvtxFile(evtxPath);
tempPath = Path.Combine(Path.GetTempPath(), $"am-evtx-{Guid.NewGuid():N}.evtx");
var psi = new System.Diagnostics.ProcessStartInfo
{
@@ -625,6 +706,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
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;
}
@@ -632,11 +714,17 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Runtime
{
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;
}
}
return File.Exists(tempPath) ? tempPath : null;
if (File.Exists(tempPath))
{
ManifestLine($"EVTX EXPORTED: channel '{channel}' -> {evtxPath}");
return tempPath;
}
return null;
}
catch (Exception ex)
{
@@ -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);
}
}
}
@@ -44,7 +44,9 @@ try {
}
Write-Log "Bootstrap script downloaded to $dest - executing."
& $dest *>> $log
# 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 {
@@ -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),
@@ -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>
);