In round 6 of the validation kit I wanted a remediation that only detected. Graph’s deviceHealthScript assignment has a property for that, runRemediationScript, described as whether to run the detection script only or both. I set it to false and assigned the policy to both test devices.

The remediation ran every hour on both of them anyway: detection, remediation, detection, sixteen cycles on the joined device alone. Graph read runRemediationScript back as false on every assignment, and a PATCH to change it was refused with a 400. The portal’s “Run remediation script” is a read-out of whether the policy has a remediation script uploaded. When I recreated the policy with only detectionScriptContent, the detection ran alone, and Graph reported remediationState as skipped.

The problem lived in the tenant. Nothing in either script was wrong, and no local test of the scripts would have shown it. The earlier posts in this series test scripts before they go up. This one covers the three IntuneScriptLab commands that look at what’s already in the tenant: Test-IntuneDeployedScript, Compare-IntuneDeployedScript and Get-IntuneScriptHealth.

Connecting

The module doesn’t depend on the Graph SDK. It uses the session you connected with Microsoft.Graph.Authentication, through one private wrapper, Invoke-IslGraphRequest, which is also the seam the unit tests mock. Each command’s help lists the least privileged permission the Graph reference gives for each read:

PermissionWhat it reads
DeviceManagementScripts.Read.AllRemediations, platform scripts and their run summaries
DeviceManagementConfiguration.Read.AllAssignment filters
DeviceManagementApps.Read.AllWin32 apps
GroupMember.ReadBasic.AllMember ids and types, for the device group check
1
2
Connect-MgGraph -Scopes DeviceManagementScripts.Read.All, DeviceManagementConfiguration.Read.All,
    DeviceManagementApps.Read.All, GroupMember.ReadBasic.All

None of the three commands changes anything in the tenant. The health report’s app counts come from an export job, and creating one is a POST, which the Graph reference files as a write. That one needs DeviceManagementApps.ReadWrite.All or one of the other two ReadWrite scopes its help names, and without it the report skips the export with a warning.

The tenant commands make a request or more per policy, so a tenant with a few hundred policies can meet Graph’s throttle. Since 0.28.0 the wrapper sends a request again on a 429, 503 or 504, up to three times:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if ($status -notin 429, 503, 504) { return $null }

$delay = 0
$retryAfter = if ($response) { $response.Headers.RetryAfter } else { $null }
if ($retryAfter) {
    if ($null -ne $retryAfter.Delta) { $delay = [Math]::Ceiling($retryAfter.Delta.TotalSeconds) }
    elseif ($null -ne $retryAfter.Date) {
        $delay = [Math]::Ceiling(($retryAfter.Date.UtcDateTime - [datetime]::UtcNow).TotalSeconds)
    }
}
if ($delay -lt 1) { $delay = [Math]::Pow(2, $Attempt) }
[int][Math]::Min($delay, 60)

It waits the Retry-After the service asks for when there is one, 2, 4 and 8 seconds when there isn’t, capped at a minute. The fourth failure is thrown as it came.

The rules, with the policy’s own settings

Test-IntuneDeployedScript reads every remediation, platform script and Win32 app, pulls out each script they carry, and runs the same rules as Test-IntuneScript on it. Run against a folder, the static analysis has to infer how a script will run, from its name, a directive or a settings file, as the linting post described. Against the tenant there’s nothing to infer. The policy says whether it runs as the signed-in user, whether it’s 64-bit, and whether the signature check is on:

1
2
3
[System.IO.File]::WriteAllBytes($file, [System.Convert]::FromBase64String($Content))
$context = if ("$RunAsAccount" -eq 'user') { 'User' } else { 'System' }
$architecture = if ([bool]$RunAs32Bit) { 'x86' } else { 'x64' }

The tenant stores scripts as base64, and the pre-flight writes the decoded bytes to a temporary file as they are, BOM or no BOM, so the encoding rule judges the file the agent will get. A script decoded to text and written back out would get whatever encoding the writer chose.

I ran it against my dev tenant, which holds the validation rounds’ policies: 50 remediations, 11 platform scripts and 90 Win32 apps, most of them built to fail. It came back with 669 findings on 115 of the 151 policies, in 58 seconds. That’s too long to print here, so this is the same run grouped:

1
Test-IntuneDeployedScript -MinimumSeverity Warning | Group-Object RuleName, Severity | Sort-Object Count -Descending
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Count Name
----- ----
  326 IslContextIssue, Error
  157 IslEncodingIssue, Warning
  126 IslOutputIssue, Warning
   33 IslOutputIssue, Error
    7 IslExitCodeIssue, Warning
    6 IslAssignmentIssue, Warning
    6 IslPowerShell7Syntax, Error
    2 IslDetectionRuleIssue, Error
    2 IslModuleDependency, Warning
    2 IslScriptSize, Warning
    1 IslExitCodeIssue, Error
    1 IslSignatureIssue, Error

Don’t read too much into the shape. The 326 context errors are one fixture: every round script carries the same $env:USERPROFILE and $env:APPDATA lines at 47 and 48, and the policies that run as SYSTEM get flagged for both. A production tenant reads differently. The default view prints a header per policy and its findings under it. In this app, the script rules and a policy check land together:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
   Policy: Win32App 'ISL-W32-USER-INSTALL' (547b7e75-9425-4897-9990-e4354e569cf2)

Severity    Rule                   Role         Line Message
--------    ----                   ----         ---- -------
Warning     IslEncodingIssue       detection       1 Non-ASCII characters in a UTF-8 file without a BOM: Windows PowerShell 5.1 decodes it as ANSI and corrupts
                                                     them. Save as UTF-8 with BOM
Error       IslOutputIssue         detection       1 Nothing is ever written to stdout: exit 0 alone means "not detected". Write-Output a line before exit 0 on
                                                     the installed path
Warning     IslOutputIssue         detection      15 Get-CimInstance writes an error record to stderr when its target is missing, which makes the app 'not
                                                     detected'. Use -ErrorAction SilentlyContinue (or Test-Path first) on the not-installed path
Error       IslContextIssue        detection      47 $env:USERPROFILE resolves to the SYSTEM profile (systemprofile), not the signed-in user. Look the user up
                                                     (e.g. via explorer.exe's owner or HKU) or run in user context
Error       IslContextIssue        detection      48 $env:APPDATA resolves to the SYSTEM profile (systemprofile), not the signed-in user. Look the user up
                                                     (e.g. via explorer.exe's owner or HKU) or run in user context
Warning     IslAssignmentIssue     policy            Install behavior User, assigned to devices (b30cc03c-f6ae-4079-b469-820c9870d806): the app is never
                                                     installed there. Assign it to users

-Kind, -Name and -Id narrow the policies, and -IncludeRule, -ExcludeRule and -MinimumSeverity work the way they do on Test-IntuneScript. -Settings needs a word of its own. Locally, a settings file sits in the script’s folder and applies to everything under it. A tenant’s scripts have no folder, so if your repository carries a settings file with suppressions or a rule list, pass its path, or a hashtable, to -Settings and the pre-flight analyzes the tenant’s scripts the way your local runs do. Before 0.26.0 a variable inside the command overwrote that parameter, and the tenant runs ignored it; the overview lists that one with the others. The temporary folder is removed when the run ends.

Checks only the tenant can make

On top of the script rules, the pre-flight checks the policy itself. Each check is a result from the validation rounds, and the finding carries the experiment behind it:

RuleSeverityWhat it catches
IslDetectionRuleIssueErrorA Win32 file detection rule with doesNotExist, which the agent doesn’t evaluate
IslAssignmentIssueWarningA Win32 app with install behavior User, assigned to devices
IslDetectOnlyInformationA remediation with no remediation script, so the detection runs alone
IslFilterIssueWarning or InformationAn assignment filter with a clause no Windows device can match, or one every device matches
IslAssignmentIssueWarningA policy with no assignment, or only exclusions
IslAssignmentIssueInformationA user-context script assigned to devices
IslScheduleIssueInformationA run-once schedule whose time has passed

In the same run, the policy-level rows at Warning and above were these, next to the one in the app above and one more on a remediation that has only an exclusion:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Kind        PolicyName                  RuleName              Severity Message
----        ----------                  --------              -------- -------
Remediation ISL-ASSIGN-NONE             IslAssignmentIssue    Warning  No assignment: no device resolves the policy, so it never runs anywhere
Remediation ISL-REM-INSTALL-MODULE      IslAssignmentIssue    Warning  No assignment: no device resolves the policy, so it never runs anywhere
Win32App    ISL-W32-DEP-CHILD           IslAssignmentIssue    Warning  No assignment: no device resolves the policy, so it never runs anywhere
Win32App    ISL-W32-DEPD-CHILD          IslAssignmentIssue    Warning  No assignment: no device resolves the policy, so it never runs anywhere
Win32App    ISL-W32-FILE-NOTEXIST       IslDetectionRuleIssue Error    Detection rule 1 is a file rule with detectionType doesNotExist (C:\ProgramData\IntuneScriptLab\Fixtures\missing.txt): the agent
                                                                       does not evaluate it. Use a registry doesNotExist rule or a script
Win32App    ISL-W32-FILE-NOTEXIST-FALSE IslDetectionRuleIssue Error    Detection rule 1 is a file rule with detectionType doesNotExist (C:\ProgramData\IntuneScriptLab\Fixtures\present.txt): the agent
                                                                       does not evaluate it. Use a registry doesNotExist rule or a script

The file rule and the user-context app are in the Win32 post, and the filter values are in the assignment filter post. The detect-only note is the opening story, and it’s Information because a detection that runs alone is a legitimate policy. The others came from round 9, which deployed the same remediation six times with different assignments.

A policy with no assignment never reached either device’s list of resolved policies. An exclusion with no include behaved the same way: an exclusion on its own doesn’t mean “everyone but this group”, it means nobody.

A run-once schedule whose time has already passed doesn’t make the policy dead. A device that fetched it two hours after the scheduled time ran it once at the fetch, about six minutes later, and never again. The note says so, because “it’s in the past, so it’s done” is the wrong reading for a device that hasn’t seen it yet.

A user-context remediation or platform script assigned to a device group runs as the signed-in user on Entra joined and hybrid joined devices. On the Entra registered device the agent logged This is not AADJ/HAADJ device, skip user context for every one of them, and never ran them.

Deciding whether a group is a device group took two tries. The first version read a page of members, twenty of them, and judged the group from those. A mixed group whose first page happened to be all devices passed as a device group, and a user-context app assigned to it got a warning it didn’t deserve. Since 0.28.0 the check counts the members and the devices among them:

1
2
3
4
5
$countSplat = @{ Headers = @{ ConsistencyLevel = 'eventual' } }
$total = [int](Invoke-IslGraphRequest -Uri "/v1.0/groups/$GroupId/members/`$count" @countSplat)
$deviceUri = "/v1.0/groups/$GroupId/members/microsoft.graph.device/`$count"
$devices = [int](Invoke-IslGraphRequest -Uri $deviceUri @countSplat)
$isDeviceGroup = $total -gt 0 -and $devices -eq $total

$count on a group’s members needs the ConsistencyLevel: eventual header, and it answers with the number as plain text, not JSON. Each group is looked up once per run. When the session lacks the scope, the first failure is reported once and the rest of the run skips the group check, and -SkipGroupLookup turns it off on purpose.

What Graph doesn’t show you

Round 9 found one assignment shape that no pre-flight can catch. A policy assigned to a group as an include and to the same group as an exclusion: Graph accepted the POST /assign with both, and GET /assignments returned only the include. The joined device resolved the policy and inspected its schedule, and then the runner never executed it, while the 25 other remediations in the same cycle ran. The exclusion held on the device, and the API gave no sign of it. That one is written up in Validation/Findings.md and nowhere in the commands, because there’s nothing for a command to read.

Two more Graph details shaped the commands. A Win32 app’s isAssigned property read false for 30 minutes and more after the apps were assigned, including apps already installed on the device, so the commands read the assignments themselves. And the per-app install status endpoints that older posts and docs use, mobileApps/{id}/deviceStatuses and reports/getDeviceInstallStatusReport, answered Resource not found on beta. The working path is an export job, which the health report uses.

Drift against git

The pre-flight answers “is what’s deployed any good”. Compare-IntuneDeployedScript answers “is what’s deployed what I committed”. Point it at a checkout and it finds the local file for every script in the tenant and compares the two byte for byte.

To try it, I downloaded every script in the dev tenant byte for byte into the convention’s layout, 183 files, and then made four edits: stripped the BOM from one detection script, turned one remediation’s CRLF into LF, appended a comment to the last line of a platform script, and deleted one detection script.

1
Compare-IntuneDeployedScript -Path C:\Repos\intune-scripts-lab | Where-Object State -ne InSync
1
2
3
4
5
6
7
Kind           Policy                     Role        State       Differences          Detail
----           ------                     ----        -----       -----------          ------
Remediation    ISL-REM-ENC-BOM            detection   Drifted     Bom                  the local file has no BOM, the tenant's has one
Remediation    ISL-REM-FIX                remediation Drifted     LineEndings          line endings LF locally, CRLF in the tenant
Remediation    ISL-REM-DETECTONLY         detection   Missing                          no local file for the detection script of 'ISL-REM-DETECTONLY' under C:\Repos\intune-scripts-lab
PlatformScript ISL-PS-OUT                 script      Drifted     Content              content differs from line 65: local 'exit 0 # edited after upload', tenant 'exit 0' (1 line(s) only local, 1
                                                                                       only in the tenant)

That took 39 seconds, and the other 179 came back InSync. Each result also carries the local path, both hashes shortened to twelve characters, and the policy’s last modified time, so a drifted script tells you how old the tenant’s copy is.

Local files are found by convention. A remediation named Fix-Widget is a folder of that name holding Detect*.ps1 and Remediat*.ps1. A platform script Set-Proxy is Set-Proxy.ps1 anywhere under the path, or the single script in a folder of that name. A Win32 app is a folder with Detect*.ps1 and Requirement*.ps1. Names match without regard to case, with anything other than a letter, a digit, ., _ or -, spaces included, read as an underscore on both sides. The app Widget: 2.0 becomes widget__2.0, so it finds a folder named Widget_ 2.0 or widget__2.0. When your repository’s layout is different, -Map names the files:

1
2
3
4
5
$map = @{
    'Fix-Widget' = @{ Detection = '.\widget\check.ps1'; Remediation = '.\widget\repair.ps1' }
    'Set-Proxy'  = '.\network\proxy.ps1'
}
Compare-IntuneDeployedScript -Path . -Map $map

When the bytes differ, the comparison works out what kind of difference it is, because the kinds mean different things on the device:

1
2
3
4
$localText = ConvertTo-Text -Bytes $Local
$tenantText = ConvertTo-Text -Bytes $Tenant
if ($localText.Bom -ne $tenantText.Bom) {
    $differences.Add('Bom')

A BOM difference is the one that changes behavior. Windows PowerShell 5.1 reads UTF-8 without a BOM as ANSI, so a script with a non-ASCII character works from your editor and turns Grüße into Grüße on the device; the linting post has the details. The other kinds are LineEndings, Whitespace (trailing spaces or blank lines at the end) and Content, plus Settings below. Line endings and whitespace are reported apart from content so a CRLF-to-LF conversion by git doesn’t look like someone edited the logic. When the content does differ, Detail names the first line that differs, an excerpt from each side, and how many lines each side has alone.

The states cover the cases where there’s nothing to compare: Missing when the tenant has a script and the folder doesn’t, Ambiguous when more than one local file could be it, and NotInTenant when a local file exists for a role the policy doesn’t carry, a remediation script for a detect-only policy, say. Everything that isn’t InSync is something to look at, so Where-Object State -ne InSync returning nothing is the gate.

One more comparison runs when you’ve said how a script should run. If a local script carries a directive, # IntuneScriptLab: Context=User Architecture=x64, or a settings file sets those keys, the policy’s run-as account, bitness and signature check are compared with it, and a mismatch is a Settings difference. Values the module would only have inferred aren’t compared, since a guess disagreeing with the tenant says nothing.

One line per policy

Get-IntuneScriptHealth puts the other two together with what the devices told Intune, one object per policy:

1
Get-IntuneScriptHealth -Path C:\Repos\intune-scripts-lab | Where-Object Health -ne Healthy

Against the dev tenant and the same folder, it took 3 minutes 6 seconds for 151 policies, the export job included, and sorted them into 124 Broken, 6 Attention and 21 Healthy. The first rows of the 130 that aren’t Healthy, with the four planted drifts among them:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Health    Kind           Policy                     Err Warn Drift        OK  Fail Notes
------    ----           ------                     --- ---- -----        --  ---- -----
Broken    Remediation    ISL-REM-OUT-HOSTLAST         4    4 InSync        2     0 4 error finding(s); 4 warning finding(s)
Broken    Remediation    ISL-REM-PROBE-SYS64          4    3 InSync        2     0 4 error finding(s); 3 warning finding(s)
Broken    Remediation    ISL-ASSIGN-EXCLONLY          4    2 InSync        0     0 4 error finding(s); assigned to nobody; 2 warning finding(s)
Broken    Remediation    ISL-REM-OUT-STREAMS          4    3 InSync        2     0 4 error finding(s); 3 warning finding(s)
Broken    Remediation    ISL-ASSIGN-PAST              4    1 InSync        0     0 4 error finding(s); 1 warning finding(s); 4 device(s) still detect the issue
Broken    Remediation    ISL-REM-EXIT-2               4    4 InSync        0     0 4 error finding(s); 4 warning finding(s); 4 device(s) still detect the issue
Attention Remediation    ISL-REM-PROBE-USER64         0    3 InSync        1     0 3 warning finding(s)
Broken    Remediation    ISL-REM-ENC-BOM              4    1 Drifted       2     0 4 error finding(s); 1 warning finding(s); drift: Drifted
Broken    Remediation    ISL-REM-REMFAIL              4    4 InSync        0     2 4 error finding(s); every device that ran it failed; 4 warning finding(s); 2 device(s) still detect the issue
Broken    Remediation    ISL-REM-FIX                  4    3 Drifted       2     0 4 error finding(s); 3 warning finding(s); drift: Drifted
Attention Remediation    ISL-POST-FEEDBACK-FIXED      0    1 InSync        1     0 1 warning finding(s); 1 device(s) still detect the issue
Broken    Remediation    ISL-POST-FEEDBACK-RECURRED   0    1 InSync        0     1 every device that ran it failed; 1 warning finding(s); 1 device(s) still detect the issue
Broken    Remediation    ISL-REM-DETECTONLY           2    1 Missing       0     0 2 error finding(s); 1 warning finding(s); 2 device(s) still detect the issue; drift: Missing
Broken    Remediation    ISL-REM-INSTALL-MODULE       4    5 InSync        0     2 4 error finding(s); assigned to nobody; every device that ran it failed; 5 warning finding(s)
Attention Remediation    ISL-POST-FEEDBACK-RECURRED   0    0 InSync        0     0 2 device(s) still detect the issue
                         -SILENT
Broken    PlatformScript ISL-PS-FAIL-2                2    1 InSync        0     2 2 error finding(s); every device that ran it failed; 1 warning finding(s)
Broken    PlatformScript ISL-PS-OUT                   2    1 Drifted       2     0 2 error finding(s); 1 warning finding(s); drift: Drifted
Broken    Win32App       ISL-W32-DEP-CHILD            2    3 InSync        0     0 2 error finding(s); assigned to nobody; 3 warning finding(s)
Broken    Win32App       ISL-W32-DEPD-PARENT          2    2 InSync        0     0 2 error finding(s); 2 warning finding(s); no device has reported yet
Broken    Win32App       ISL-W32-FILE-NOTEXIST        1    0 NoScript      0     2 1 error finding(s); every device that ran it failed
Broken    Win32App       ISL-W32-FILTER-INCLUDE       3    2 InSync        0     1 3 error finding(s); every device that ran it failed; 2 warning finding(s); 1 device(s) not applicable
Broken    Win32App       ISL-W32-REQ-ARCH-ARM64       3    2 InSync        0     0 3 error finding(s); 2 warning finding(s); no device has reported yet; 2 device(s) not applicable

The Drift column reads NoScript for an app whose detection is rules only, since there’s no script to compare.

For each policy you get the pre-flight’s findings counted by severity, the drift state when -Path is given, a summary of the assignment (include targets, exclusions, filters), and the devices’ counts. For a remediation those come from its runSummary: no issue or remediated, still detected or back, a script error, pending, and not applicable. A platform script’s runSummary has succeeded and failed. A Win32 app’s counts come from one AppInstallStatusAggregate export job, which covers every app in the tenant in about twenty seconds, where the per-app route would mean one job per app.

The verdict is blunt. Broken means an Error finding, a policy assigned to nobody, or a policy every device that ran it failed. Attention means warnings, some failures, a remediation whose issue stays detected, drift, devices the policy wasn’t applicable to, or an assigned policy no device has reported on yet. Everything else is Healthy, and Notes says why for the other two. “Assigned to nobody” is read from the assignments themselves, so -SkipAnalysis doesn’t hide it; until 0.26.0 it came from a finding, and skipping the analysis called an unassigned policy Healthy.

-MarkdownPath writes the same report as a table per kind, Broken first, for a wiki page or a pull request comment.

Reading the counts late

The run states in the health report lag the devices, by amounts that differ per kind. I measured them on 2026-09-29 against the agent’s own log lines:

KindDevice to Graph
Remediation, changed result65 minutes
Remediation, unchanged resultNot reported again
Platform script2 to 8 seconds
Win32 app install state30 to 39 seconds

The remediation number is the one that surprises people. The fix happened on the device at 01:37 UTC, and Graph had it at 02:42. The agent doesn’t report a remediation’s result when it runs. It uploads the results in a batch with the next hourly cycle, twenty policies in one request that time, and Graph wrote the state within seconds of that upload.

A result that hasn’t changed isn’t reported at all. The agent logs app result is the same as cached one, no need to save, so lastStateUpdateDateTime on a remediation that has said “no issue” every hour for a week is the moment it first said so. A count that’s days old isn’t stale. For a policy you changed a few minutes ago, -SkipRunState judges it on its scripts and assignments and leaves the counts out.

The device’s own view is in its logs, and the log post covers reading a remediation’s report line and what its result codes mean against Graph’s run states.

Where the tenant view stops

These commands read what the tenant holds and what devices reported to it. A few things sit outside that.

The include-and-exclude assignment above is invisible after the fact. The pre-flight can’t see the exclusion Graph doesn’t return, and the device shows only a policy that never runs.

Run states are a summary of reports, and reports lag. What a device is doing right now, and why, is in the agent’s logs on that device.

The rules are the same rules as the local analysis, measured on Windows 11 24H2 lab devices, and they share its limits. A finding cites the experiment it’s based on, so you can see what a rule assumed when your devices disagree.

The pre-flight runs locally on downloaded content: the scripts are written to a temporary folder byte for byte, analyzed, and deleted. Running it on a schedule against production leaves production as it was, and Graph’s throttle is the only cost. The code in this post is from IntuneScriptLab 0.31.0.