feat(agent,web,mcp): surface the 24H2 location gate instead of only logging it

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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-08-26 13:58:31 +02:00
parent d89e3f0b48
commit 0f9a77c848
8 changed files with 132 additions and 4 deletions
+24
View File
@@ -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.
+5
View File
@@ -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.
@@ -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; ")]
@@ -22,6 +22,23 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// </summary>
internal static class WifiInfoProvider
{
/// <summary>
/// <see cref="WifiConnectionInfo.DataLimitedReason"/> value for a payload cut down to the
/// SSID by the Windows 11 24H2 location gate. Travels verbatim into the
/// <c>wifi_signal_info</c> payload as <c>wifiDataLimitedReason</c> and is matched by the
/// portal's WiFi card — a published contract, so do not rename it.
/// </summary>
internal const string LocationServicesOff = "location_services_off";
/// <summary>
/// True when the native tier reported <c>ERROR_ACCESS_DENIED</c> — "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.
/// </summary>
internal static bool IsLocationGateDenied(string nativeDiag)
=> !string.IsNullOrEmpty(nativeDiag) && Regex.IsMatch(nativeDiag, @"rc=5(?!\d)");
/// <summary>
/// Reads the current WiFi connection, walking the tiers until one yields something.
/// <paramref name="preferredInterfaceId"/> 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; "
@@ -25,6 +25,14 @@ namespace AutopilotMonitor.Agent.V2.Core.Monitoring.Interop
/// <summary>Current 802.11 channel number; null when the channel query fails.</summary>
public int? Channel { get; internal set; }
/// <summary>
/// Why the payload is incomplete, when that is known — currently only
/// <c>location_services_off</c>: 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.
/// </summary>
public string DataLimitedReason { get; internal set; }
}
/// <summary>
@@ -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);
}
@@ -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' },
@@ -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 && <DetailRow label="Radio" value={wifiSignalInfo.wifiRadioType} />}
{wifiSignalInfo.wifiChannel !== undefined && <DetailRow label="Channel" value={`${wifiSignalInfo.wifiChannel}`} />}
{wifiSignalInfo.wifiDataLimitedReason === "location_services_off" && (
<div className="mt-2 text-xs text-gray-500">
{"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)."}
</div>
)}
</div>
)}