From 0f9a77c8480b2949e954a161a2adef5aa47d64b5 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Wed, 26 Aug 2026 13:58:31 +0200 Subject: [PATCH] feat(agent,web,mcp): surface the 24H2 location gate instead of only logging it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A missing WiFi signal reading is indistinguishable from a bug unless the payload says why, and the device-level location setting is not something an admin would think to check. Since 058248d9 the SSID survives the gate via the WinRT tier — but signal, radio and channel silently vanish with no explanation anywhere. When the WinRT tier wins AND the native tier reported rc=5, wifi_signal_info now carries wifiDataLimitedReason = "location_services_off" and the event message gains "— signal unavailable, Location services off", so the reason reads straight off the timeline. The portal's WiFi card matches that literal and names the Intune setting that restores the full payload. The claim is evidence-only: any other native failure (no WLAN service, no connected interface) leaves the field null rather than blaming a privacy setting we did not observe. IsLocationGateDenied is the single predicate, pinned at its boundaries (rc=5 yes; rc=50, rc=5023, rc=1062 no), and the literal is pinned as the published agent/portal/MCP contract. New wifi_signal_info group in the MCP DEVICE_PROPERTIES_CATALOG, so a fleet can be sized before anyone changes a policy: search_sessions(deviceProperties: { "wifi_signal_info.wifiDataLimitedReason": "location_services_off" }) Backend is untouched — the event payload is opaque DataJson with no projection. network_state_change and the network timeline deliberately do not carry the reason yet; that is a second payload plus web change for no extra insight. Verified: build 0 warnings, agent suite 2553/2553, web tsc + eslint (0 warnings) + vitest 889/889, MCP tsc. Co-Authored-By: Claude Opus 5 (1M context) --- docs/agent/wifi-ssid-location-gate.md | 24 ++++++++++ docs/log.md | 5 ++ .../Interop/WifiInfoProviderTests.cs | 46 +++++++++++++++++++ .../Monitoring/Interop/WifiInfoProvider.cs | 29 ++++++++++-- .../Monitoring/Interop/WifiInfoReader.cs | 8 ++++ .../DeviceInfoCollector.NetworkAndSecurity.cs | 6 +++ .../src/resource-catalog.ts | 11 +++++ .../sessions/components/DeviceDetailsCard.tsx | 7 ++- 8 files changed, 132 insertions(+), 4 deletions(-) diff --git a/docs/agent/wifi-ssid-location-gate.md b/docs/agent/wifi-ssid-location-gate.md index d0ae9207..b6e9d677 100644 --- a/docs/agent/wifi-ssid-location-gate.md +++ b/docs/agent/wifi-ssid-location-gate.md @@ -85,6 +85,30 @@ change — the same mechanism [OobeStateReader](../../src/Agent/AutopilotMonitor already uses from the SYSTEM service context. Every step degrades to null rather than throwing, because the caller runs fire-and-forget inside `Task.Run`. +# Telling the Operator + +A missing signal reading is indistinguishable from a bug unless the payload says why, and the +device-level location setting is not something an admin would think to check. When tier 2 wins +*and* the native tier reported `rc=5`, the `wifi_signal_info` payload therefore carries + +``` +wifiDataLimitedReason = "location_services_off" +``` + +and the event message gains `- signal unavailable, Location services off`, so the reason is visible +in the timeline itself. The portal's WiFi card (`DeviceDetailsCard`) matches that literal and +renders the explanation plus the Intune setting that fixes it. The value is a published contract — +agent, portal and the MCP device-property catalog all match the same string. + +The claim is only made on observed evidence: any other native failure (no WLAN service, no +connected interface) leaves the field null rather than blaming a privacy setting we did not +measure. `WifiInfoProvider.IsLocationGateDenied` is the single predicate, pinned by tests at its +boundaries (`rc=5` yes; `rc=50`, `rc=5023`, `rc=1062` no). + +Fleet-wide the same field is queryable through +`search_sessions(deviceProperties: { "wifi_signal_info.wifiDataLimitedReason": "location_services_off" })`, +which sizes how much of a fleet is affected before anyone changes a policy. + Tier 2 returns the SSID alone. Signal quality, PHY type and channel have no ungated source; they stay null when the native tier is denied. That is deliberate — deriving a percentage from `ConnectionProfile.GetSignalBars()` (0–5) would fabricate precision the API does not have. diff --git a/docs/log.md b/docs/log.md index 4c6284ff..5f460457 100644 --- a/docs/log.md +++ b/docs/log.md @@ -1,5 +1,10 @@ # Log +## 2026-08-26 + +* **Update**: `agent/wifi-ssid-location-gate.md` - the gate is now surfaced instead of only logged. When the WinRT tier wins and the native tier reported `rc=5`, `wifi_signal_info` carries `wifiDataLimitedReason = "location_services_off"` and the message gains `- signal unavailable, Location services off`, so the reason is readable straight from the timeline; the portal WiFi card (`DeviceDetailsCard`) matches that literal and names the Intune setting that restores the full payload. The claim is evidence-only - any other native failure leaves the field null rather than blaming a privacy setting, with `IsLocationGateDenied` pinned at its boundaries. New `wifi_signal_info` group in the MCP `DEVICE_PROPERTIES_CATALOG` so a fleet can be sized via `search_sessions` before any policy changes. Agent release + web deploy + MCP redeploy; backend untouched (the payload is opaque DataJson). + + ## 2026-08-26 * **New**: `agent/wifi-ssid-location-gate.md` - `wifi_signal_info` was missing on most Windows 11 24H2+ WiFi enrollments (build 22631 3/3, 26100 37/178, 26200 43/95 over n=276 sessions from 08-01). Not a parser or language problem: every event that exists carries a full SSID, so the failure is binary at the reader. Cause is the 24H2 location gate on `WlanQueryInterface` with `wlan_intf_opcode_current_connection` - the only opcode the agent uses - which returns `ERROR_ACCESS_DENIED`; the consent prompt only fires for a process in the user context outside `System32`, so the agent (SYSTEM, session 0, pre-privacy-page) is denied silently, and it resolves consent from the HKLM ConsentStore because the `S-1-5-18` hive has none - while the interactive user on the same box reads the SSID fine via the per-user NonPackaged value. `netsh wlan` hangs off the same gate, so the 2026-07 netsh fallback (and its multi-language labels) never covered the case it was built for; more labels cannot fix it. New `WinRtWifiSsidReader` uses the documented replacement `WlanConnectionProfileDetails.GetConnectedSsid` (ungated, SSID only), bound via the CLR WinRT projection with no winmd/NuGet like `OobeStateReader`; new `WifiInfoProvider` owns the tier order (native, WinRT, netsh) that both collectors had duplicated. The step diagnostics added in 2026-07 were logged at `Debug` while the default level is `Info`, so they never reached a field log - the all-tiers-failed case is now a Warning that decodes `rc=5` into the location-gate explanation. Agent-only change; needs an agent release. diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs index c230de75..75689ef7 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/Monitoring/Interop/WifiInfoProviderTests.cs @@ -78,6 +78,52 @@ namespace AutopilotMonitor.Agent.V2.Core.Tests.Monitoring.Interop Assert.Contains("ERROR_ACCESS_DENIED", text); } + // ----- IsLocationGateDenied ----------------------------------------- + // + // This predicate decides whether the wifi_signal_info payload claims + // "location_services_off" to the operator. A false positive blames a privacy setting + // that is actually fine, so the boundaries are pinned exactly. + + [Theory] + [InlineData("[guid] query(current_connection) rc=5; ")] + [InlineData("WlanOpenHandle rc=5")] + public void IsLocationGateDenied_true_only_for_error_access_denied(string nativeDiag) + { + Assert.True(WifiInfoProvider.IsLocationGateDenied(nativeDiag)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("query(current_connection) rc=50; ")] + [InlineData("query(current_connection) rc=5023; ")] + [InlineData("WlanEnumInterfaces rc=1062")] + [InlineData("no WLAN interfaces")] + [InlineData("[guid] state=wlan_interface_state_disconnected; ")] + public void IsLocationGateDenied_false_for_every_other_reason(string? nativeDiag) + { + Assert.False(WifiInfoProvider.IsLocationGateDenied(nativeDiag)); + } + + [Fact] + public void LocationServicesOff_literal_is_the_published_payload_contract() + { + // Matched verbatim by the portal's WiFi card (wifiDataLimitedReason) — renaming it + // silently drops the hint instead of breaking a build. + Assert.Equal("location_services_off", WifiInfoProvider.LocationServicesOff); + } + + [Fact] + public void TryRead_never_claims_the_location_gate_on_the_native_tier() + { + // The native tier only returns when it succeeded, so a full payload must never carry + // a "data limited" reason. + var result = WifiInfoProvider.TryRead(null, out _); + + if (result?.SignalPercent != null) + Assert.Null(result.DataLimitedReason); + } + [Theory] [InlineData("query(current_connection) rc=50; ")] [InlineData("query(current_connection) rc=5023; ")] diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs index 5e484f32..6932ccba 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoProvider.cs @@ -22,6 +22,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop /// internal static class WifiInfoProvider { + /// + /// value for a payload cut down to the + /// SSID by the Windows 11 24H2 location gate. Travels verbatim into the + /// wifi_signal_info payload as wifiDataLimitedReason and is matched by the + /// portal's WiFi card — a published contract, so do not rename it. + /// + internal const string LocationServicesOff = "location_services_off"; + + /// + /// True when the native tier reported ERROR_ACCESS_DENIED — "rc=5" but not + /// "rc=50"/"rc=5023"; the diagnostics string ends the code with ';' or end-of-line. + /// That is the 24H2 location gate, not a driver or hardware fault. + /// Internal for direct unit-testing via InternalsVisibleTo. + /// + internal static bool IsLocationGateDenied(string nativeDiag) + => !string.IsNullOrEmpty(nativeDiag) && Regex.IsMatch(nativeDiag, @"rc=5(?!\d)"); + /// /// Reads the current WiFi connection, walking the tiers until one yields something. /// is the adapter GUID from @@ -42,7 +59,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop if (ssid != null) { diagnostics = null; - return new WifiConnectionInfo { Ssid = ssid }; + return new WifiConnectionInfo + { + Ssid = ssid, + // Only claim the location gate when the native tier actually said so. Any + // other native failure (no WLAN service, no connected interface) leaves this + // null rather than blaming a setting we did not observe. + DataLimitedReason = IsLocationGateDenied(nativeDiag) ? LocationServicesOff : null, + }; } var netsh = NetshWifiFallback.TryRead(); @@ -69,8 +93,7 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop text.Append("; WinRT: ").Append(string.IsNullOrEmpty(winRtDiag) ? "no SSID" : winRtDiag); text.Append("; netsh: no parsable output"); - // "rc=5" but not "rc=50"/"rc=5023" — the diagnostics string ends the code with ';' or EOL. - if (!string.IsNullOrEmpty(nativeDiag) && Regex.IsMatch(nativeDiag, @"rc=5(?!\d)")) + if (IsLocationGateDenied(nativeDiag)) { text.Append(". rc=5 is ERROR_ACCESS_DENIED — Windows 11 24H2+ gates the current-connection " + "opcode (and netsh wlan) behind precise-location consent, which is off in this image; " diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs index c0a636b4..8912606d 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Interop/WifiInfoReader.cs @@ -25,6 +25,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop /// Current 802.11 channel number; null when the channel query fails. public int? Channel { get; internal set; } + + /// + /// Why the payload is incomplete, when that is known — currently only + /// location_services_off: the SSID came from the ungated WinRT tier because the + /// native API was denied by the Windows 11 24H2 location gate, so signal, PHY and + /// channel have no source. Null whenever the reason is unknown; never a guess. + /// + public string DataLimitedReason { get; internal set; } } /// diff --git a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs index 4151cfc7..a618b607 100644 --- a/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs +++ b/src/Agent/AutopilotMonitor.Agent.V2.Core/Monitoring/Telemetry/DeviceInfo/DeviceInfoCollector.NetworkAndSecurity.cs @@ -954,12 +954,18 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Telemetry.DeviceInfo data["wifiRadioType"] = wifi.RadioType; if (wifi.Channel.HasValue) data["wifiChannel"] = wifi.Channel.Value; + if (wifi.DataLimitedReason != null) + data["wifiDataLimitedReason"] = wifi.DataLimitedReason; var message = wifi.Ssid != null ? $"WiFi: {wifi.Ssid}" : "WiFi signal info"; if (wifi.SignalPercent.HasValue) message += $", Signal: {wifi.SignalPercent.Value}%"; if (wifi.RadioType != null) message += $" ({wifi.RadioType})"; + // Say it in the timeline too — a missing signal reads like a bug otherwise, and + // this is the only place an admin ever learns that a privacy setting caused it. + if (wifi.DataLimitedReason == WifiInfoProvider.LocationServicesOff) + message += " — signal unavailable, Location services off"; EmitDeviceInfoEvent(Constants.EventTypes.WifiSignalInfo, message, data); } diff --git a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts index 04ca503f..bc24d74d 100644 --- a/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts +++ b/src/McpServer/autopilot-monitor-mcp/src/resource-catalog.ts @@ -312,6 +312,17 @@ export const DEVICE_PROPERTIES_CATALOG = { 'network_interface_info.adapterName': { type: 'string', description: 'Network adapter name' }, 'network_interface_info.macAddress': { type: 'string', description: 'MAC address of active adapter' }, }, + wifi_signal_info: { + 'wifi_signal_info.wifiSsid': { type: 'string', description: 'SSID of the WiFi network the device enrolled over' }, + 'wifi_signal_info.wifiSignalPercent': { type: 'number', description: 'WiFi signal quality 0-100. Use <=N to find weak-signal enrollments (e.g. "<=40")' }, + 'wifi_signal_info.wifiRadioType': { type: 'string', description: 'PHY generation, e.g. "802.11ac", "802.11ax", "802.11be"' }, + 'wifi_signal_info.wifiChannel': { type: 'number', description: '802.11 channel number' }, + 'wifi_signal_info.wifiDataLimitedReason': { + type: 'string', + description: + 'Why the WiFi payload is SSID-only. Currently one value: "location_services_off" — Windows 11 24H2+ gates the native WLAN API behind precise-location consent, which is off in that device image, so signal/radio/channel could not be read. Filter on it to size how much of a fleet is affected.', + }, + }, aad_join_status: { 'aad_join_status.joinType': { type: 'string', description: 'Azure AD join type' }, 'aad_join_status.tenantId': { type: 'string', description: 'Entra ID tenant' }, diff --git a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx index d20d426f..284ec0a2 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/components/DeviceDetailsCard.tsx @@ -16,7 +16,7 @@ interface OsInfoData { [key: string]: unknown; osVersion?: string; displayVersio interface NetworkAdaptersData { [key: string]: unknown; adapters?: unknown[] } interface DnsConfigData { [key: string]: unknown; dnsEntries?: unknown[] } interface NetworkInterfaceInfoData { [key: string]: unknown; status?: string; connectionType?: string; adapterDescription?: string; linkSpeedMbps?: number; gateways?: string } -interface WifiSignalInfoData { [key: string]: unknown; wifiSsid?: string; wifiSignalPercent?: number; wifiRadioType?: string; wifiChannel?: number } +interface WifiSignalInfoData { [key: string]: unknown; wifiSsid?: string; wifiSignalPercent?: number; wifiRadioType?: string; wifiChannel?: number; wifiDataLimitedReason?: string } interface ProxyConfigData { [key: string]: unknown; proxyType?: string; type?: string; proxyServer?: string; autoConfigUrl?: string; winHttpProxy?: string } interface AutopilotProfileData { [key: string]: unknown; PolicyDownloadDate?: string; AutopilotCreationDate?: string; autopilotModeLabel?: string; domainJoinMethodLabel?: string } interface ImeVersionData { [key: string]: unknown; version?: string; agentVersion?: string } @@ -330,6 +330,11 @@ export default function DeviceDetailsCard({ events, latestAgentVersion, session )} {wifiSignalInfo.wifiRadioType && } {wifiSignalInfo.wifiChannel !== undefined && } + {wifiSignalInfo.wifiDataLimitedReason === "location_services_off" && ( +
+ {"Signal, radio and channel are unavailable because Location services are off on this device. Since Windows 11 24H2 those Wi-Fi details require location access, and the agent runs as SYSTEM with no way to ask for it. The SSID is unaffected. To collect the full details, enable Location services via Intune (Privacy → Let Apps Access Location → Force Allow)."} +
+ )} )}