Take a remediation pair, run in user context, that turns off Windows’ feedback requests. Windows keeps that setting in a per-user key that doesn’t exist until somebody changes the setting, HKCU:\Software\Microsoft\Siuf\Rules. The detection reads the value and exits 1 when the requests aren’t off:
| |
The remediation writes the value and says so:
Both read fine in a review. I ran the pair through the harness on a machine where the key had never been created:
| |
Set-ItemProperty doesn’t create a missing key. It wrote Cannot find path 'HKCU:\Software\Microsoft\Siuf\Rules' because it does not exist to stderr, the error was non-terminating, and the script carried on to print “Feedback requests turned off” and exit 0. The remediation’s own output claims success. Intune doesn’t believe it: anything on the remediation’s stderr makes the run a script error, the post-detection never runs, and the portal shows Failed with that error text next to it. IntuneError in the block above is empty because it belongs to the pre-detection; the remediation’s stderr is on $result.Remediation.StdErr, and the warning is what tells you it decided the status.
Until 0.28.0 the harness said Recurred for that run. It had learned in round 1 that a remediation exiting 0 runs the post-detection, and nobody had run a remediation that exits 0 and writes to stderr past a real agent. The first draft of this post did, by accident, and the device disagreed with the harness. Round 11’s runs below settled it, and 0.29.0 follows the device.
The static side caught it this time, too. Test-IntuneScript on the remediation:
| |
Repair-IntuneScript applies that one: -ErrorAction Stop on the Set-ItemProperty, so the script exits 1 with the real error instead of printing success on top of it. Creating the key when it’s missing (if (-not (Test-Path $key)) { New-Item -Path $key -Force | Out-Null }) is the fix, and the pair comes back Fixed.
Static analysis reads your script. It can’t tell you which branch runs on a device that has the problem, what a cmdlet prints when its target is missing, or whether the value your remediation writes is the one your detection reads back. For that you have to run the script, and running it at your own prompt answers a different question from the one Intune asks.
The linting post covered Test-IntuneScript. This one covers the commands that run things: Invoke-IntuneRemediationTest for detection and remediation pairs and Invoke-IntunePlatformScriptTest for platform scripts, plus the plumbing underneath them that launches Windows PowerShell the way the agent does, as you, as SYSTEM, or as another account. Win32 apps use the same plumbing with their own rules on top, and they get their own post.
A remediation run, start to finish
| |
The command does what the agent does with a remediation policy. It runs the detection script. When that exits 0, it stops there. If it exits anything else, it runs the remediation, and a remediation that exits 0 with nothing on stderr gets the detection again to see whether the fix held. The status comes out of those three exit codes and the remediation’s stderr:
| |
That mapping came from the device’s own bookkeeping. The agent stores a RemediationStatus per policy in the registry, and round 1 tied each code to a flow: 4 for a detection that exited 0, 1 for remediated and then detected clean, 2 for a post-detection that still failed, 3 for a remediation that exited non-zero, with the post-detection skipped. Round 11 added the clause round 1 had no experiment for: 3 is also what a remediation gets for exiting 0 with anything on stderr.
Each round 11 remediation ran once on a lab device in user context, against the missing feedback key:
| Remediation script | Device RemediationStatus | Graph detectionState / remediationState | Post-detection |
|---|---|---|---|
as written above: exit 0, the error on stderr | 3 | fail / scriptError | not run |
-ErrorAction Stop (exit 1) | 3 | fail / scriptError | not run |
-ErrorAction SilentlyContinue (exit 0, nothing on stderr, key still missing) | 2 | fail / remediationFailed | run, still failing |
| the key created first | 1 | fail / success | run, passing |
| detection writes a cmdlet error to stderr and exits 0 | 4 | success / skipped |
AgentExecutor’s own log says Powershell exit code is 0 for the first row, so stderr alone decided it. The third row is the only way to get a Recurred out of this pair: a remediation that fails quietly. The last row is the other half of the rule: a detection’s stderr changes nothing, and the error text just rides along in the detection error field.
On its Remediations page, the portal shows the same five policies:

There’s no Failed column there. A remediation that ends in a script error counts once under “With issues” and nowhere else, so the two script-error policies (-FAILED and -RECURRED) read like detections that found a problem and left it there. -FIXED is the only one with a count under “Issue fixed” and “Total remediated”, -RECURRED-SI the only one under “Recurred”, and -DETECTERR sits under “Without issues” with its stderr nowhere in the counts.
Graph uses its own words for the same outcomes, and one of them misleads:
| Harness status | Device RemediationStatus | Graph detectionState / remediationState |
|---|---|---|
| Without issues | 4 | success / skipped |
| Fixed | 1 | fail / success |
| Recurred | 2 | fail / remediationFailed |
| Failed | 3 | fail / scriptError |
remediationFailed in Graph means the fix didn’t hold. When the remediation script itself fails, by exit code or by stderr, Graph says scriptError. If you’ve ever built a report on remediationFailed thinking it counted broken remediation scripts, it was counting recurrences.
A detection with no remediation script, the detect-only kind, gets Issue detected (no remediation script) when it finds the problem. A non-zero exit other than 1 still runs the remediation, the same as on a device, with a warning on the result saying so.
The launch
Every run goes through one private function, Invoke-IslScriptRun, and its job is to make the process look like the one AgentExecutor starts. From the probe records on the test devices, the agent launches Windows PowerShell 5.1 with -NoProfile -executionPolicy bypass -file, no -NonInteractive, working directory C:\WINDOWS\system32, from a copy of the script in a cache folder. The harness does the same:
| |
The copy is there for $PSScriptRoot. On a device the agent runs your detection from C:\WINDOWS\IMECache\HealthScripts\<policyId>_<version>\detect.ps1, so a script that dot-sources a helper next to itself, or reads a config file from its own folder, finds nothing there. Running from a copy reproduces that. The working directory defaults to System32 for the same reason: .\settings.json resolves into System32 under the agent, and it does in the harness too.
I wrote a small script that prints where it thinks it is and ran it as a platform script in both hosts. The x86 run:
Windows PowerShell 5.1, the 32-bit host, System32 as the working directory, and a $PSScriptRoot that isn’t the folder the script lives in. The user and session are mine, because this run is in user context as whoever’s running the harness. SYSTEM and other accounts come later in this post.
Picking the host
-Architecture names the PowerShell host, which isn’t always the same thing as the CPU. Get-IslHostPath resolves it:
| Value | Host | Where it exists |
|---|---|---|
x86 | SysWOW64\WindowsPowerShell\v1.0\powershell.exe | x64 devices natively, ARM64 devices emulated |
x64 | System32\WindowsPowerShell\v1.0\powershell.exe | x64 devices only |
arm64 | System32\WindowsPowerShell\v1.0\powershell.exe | ARM64 devices only |
x64 and arm64 are the same path. Which binary sits there depends on the device, so each value is refused on the other CPU:
| |
On Windows on ARM there’s no x64 PowerShell at all, which I confirmed with a local survey of an ARM64 laptop’s in-box hosts. The native host reports PROCESSOR_ARCHITECTURE=ARM64, the x86 host reports PROCESSOR_ARCHITEW6432=ARM64, and there’s a Program Files (Arm) folder nobody’s scripts check for. The agent’s own behavior on ARM64 is still marked pending in the findings, but the agent can only pick from the hosts the device has.
One more wrinkle: if the harness itself runs in a 32-bit process, asking for System32 gets you SysWOW64 through WOW64 redirection. Get-IslHostPath swaps in Sysnative in that case, so -Architecture x64 still launches the 64-bit host.
The defaults follow the portal. Remediations and platform scripts default to x86 because the portal does; Win32 detection defaults to x64. If your policies are created through Graph with the bitness left out, they run 64-bit, and you’ll want -Architecture x64 to match.
Reading the output the way Intune does
The agent captures your script’s output from a console-less powershell.exe, and that process writes in the OEM code page, 437 on a US system. That’s why non-ASCII text in a remediation’s output comes back to the portal mangled even when the script file has a BOM. The harness reads both streams the same way, and it finds the code page in the registry:
| |
The comment in that function explains the registry read: under invariant globalization, CultureInfo reports the wrong code page, and on PowerShell 7 the legacy code pages have to be registered with CodePagesEncodingProvider before GetEncoding(437) works at all.
Once the output is captured, Get-IslIntuneOutput reduces it to what a remediation reports: the last non-empty line, cut to its last 2,048 characters, and the whole stderr text cut the same way. The linting post explains where those rules come from. At runtime the harness turns any trimming into a warning on the result:
| |
Stderr means something different for each kind of script. A detection that exits 0 but wrote to stderr still reports “Without issues”, and the portal shows the error text next to that status; the harness warns about it. A remediation’s stderr is the verdict, as the round 11 runs above show. For a Win32 detection, stderr fails the detection outright, which is one of the reasons those get their own post.
Running as SYSTEM
Most remediations run as SYSTEM, and SYSTEM is where your own session lies to you the most. The probe records put the agent’s SYSTEM runs in session 0, UserInteractive false, profile C:\WINDOWS\system32\config\systemprofile, TEMP at C:\WINDOWS\TEMP. You can’t get any of that by running elevated in your own session, so -Context System doesn’t try. It registers a one-shot scheduled task for SYSTEM and runs the script through it:
A scheduled task gives no handles on the process’s streams, so the task’s action is cmd.exe redirecting them to files in the work folder and writing the exit code to a third:
/V:ON carries the exit code. With %ERRORLEVEL%, cmd expands the variable when it parses the line, before PowerShell has run, and every run would report the exit code from before the script started. Delayed expansion with !ERRORLEVEL! reads it after.
The harness then polls for the exit file, waits a moment for cmd to flush its redirections, reads the files back through the OEM code page and unregisters the task in a finally block. A timeout stops the task and then kills anything whose command line carries the run’s id, since the task can start children the scheduler doesn’t track:
| |
-Context System needs an elevated session, and the module doesn’t check your group membership to decide whether you have one. It tries to register the task and reports the refusal if that fails. The comment says why: a role check misjudges accounts like build agents that aren’t in Administrators but can register tasks.
I checked this mode on an Entra joined test VM under Windows PowerShell 5.1 against the agent’s own probe records: identity, session, working directory, host bitness, the Fixed flow and timeout cleanup all matched.
Running as someone else
User context is harder. The agent runs a user-context script as the signed-in user, inside that user’s session. On the Entra joined device that was session 2, UserInteractive true, with the user’s own profile, TEMP and APPDATA. The harness can’t impersonate the person at the keyboard, so by default user context means you.
That signed-in user cost me an afternoon while deploying the round 11 runs, so the details go here. The agent wants a licensed Entra user. With a local account at the console, the remediation runner logs “needs user context, but no user logged on now, skip it”. With an Entra user who has no Intune license, it processes the session and gets “0 script policies” for it. The harness runs user context as whoever you name; the agent is pickier.
-Credential closes most of the gap between you and that user. It runs the script as another account through the same scheduled task mechanism, and it picks how the task logs on by looking for a session first:
| |
When the account holds a session, the task is registered with an Interactive logon and runs inside that session, which is the agent’s shape. When it doesn’t, the task is registered with the password, “run whether user is logged on or not”, and runs in session 0 with the account’s profile loaded. The result’s RunAs property says which one you got, (Interactive) or (Password), so you know when the session differs from the agent’s.
Finding the sessions and working out which account the credential means took most of the work in that block.
Get-IslLogonSession lists the sessions that have a desktop. A desktop runs explorer.exe, or sihost.exe while it’s still starting, and the owner of that process is the session’s user:
| |
Up to 0.27.0 the list came from parsing query user. Windows Home editions don’t ship query.exe, so on Home every -Credential run fell back to the stored-password task, and query user prints localized text besides. Reading another account’s process owner needs an elevated session, which the scheduled task needed anyway.
The account side is where Microsoft Entra accounts broke things. 0.26.0 took the credential’s user name apart as text and looked for it in the session list. That works for a local account. For an Entra account, Windows uses a name of its own: AzureAD\ plus the display name with its spaces removed, cut at 20 characters. A lab account signed in as isl-verylongusername-test01@... with the display name “Isl Verylongdisplayname Testaccount” showed up everywhere as AzureAD\IslVerylongdisplayna: in USERNAME, as the owner of the session’s explorer.exe, and in the profile folder name. That name is neither the sign-in name nor a part of it.
In 0.26.0 a credential naming user@domain was refused outright (“No mapping between account names and security IDs was done”), and one naming AzureAD\<sign-in name> found no session and fell back to the stored-password task. The scheduler only took the Windows name for a principal; the sign-in name and the SID string were both refused at registration. Resolve-IslAccount asks Windows which account the credential means:
ConvertTo-IslAccount translates the name to a SID and the SID back to the name Windows uses. The launcher matches the session by SID, registers the task for the Windows name, and grants the run folder by SID. On the joined lab device, the sign-in name, AzureAD\<sign-in name> and the Windows name each ran the script inside the account’s session, with RunAs showing the credential as given and (Interactive), and on a Windows 11 Home machine without query.exe the session list found its console session. The Entra fix shipped in 0.27.0 and the session list in 0.28.0.
Another account can’t read your temp folder, so for these runs the script copy and its output files live under ProgramData\IntuneScriptLab\Runs, and icacls grants the account Modify on the run folder before the task starts. The grant goes by SID when the account resolves.
I compared the interactive path with the agent’s own user-context launch from round 1. The harness ran a standard local account in its console session:
| Property | Agent (round 1) | Harness, interactive task |
|---|---|---|
| Identity | the signed-in user | the account passed in -Credential |
| Session | the console session, UserInteractive true | the console session, UserInteractive true |
| Working directory | C:\WINDOWS\system32 | C:\Windows\System32 |
| Profile | the user’s USERPROFILE, TEMP, APPDATA | the account’s own profile folders |
| Command line | 64-bit powershell.exe -NoProfile -executionPolicy bypass -file <copy>, parent AgentExecutor.exe | the same command line, parent cmd.exe |
| Integrity | not recorded | Medium, not an administrator |
The stored-password path took two rounds to get right. A “run whether user is logged on or not” task needs the account to hold the “Log on as a batch job” right, and a standard user doesn’t have it by default. The first time I tried it, the scheduler refused the task with 0x80070569 and the harness waited out its whole timeout before reporting anything. 0.18.1 read that code and reported it at once.
Then the lab device changed its answer. Re-checked a day later, the same task for the same account came back with no error at all. It sat in Ready with LastTaskResult 0x00041303, “has not run yet”, forever. So the launcher now treats five seconds of that after Start-ScheduledTask as the same refusal:
| |
The error you get names the batch logon right and suggests granting it or running while the account holds a session. For an Entra account the advice changes. On the lab device a stored-password task for the Entra account registered under its Windows name and then sat in Ready the same way, with the batch logon right granted for the test and without it. Since 0.28.0 the error for an Entra account says so and leaves the right out of it.
In practice the interactive path is the one you want anyway, because it’s the one that matches the agent. Validation/New-IslHarnessUser.ps1 in the repository creates a standard lab account with a generated password stored as a DPAPI credential, and with -AutoLogon makes it the console user at the next boot.
Platform scripts
Invoke-IntunePlatformScriptTest is the simpler sibling: one run, and a run state of Success, Failed or TimedOut from the exit code. The difference that catches people is how much output Intune keeps. A remediation reports its last line, capped at 2,048 characters. A platform script’s resultMessage in Graph holds every line, Write-Host included, untruncated; a 6,000-character line came back whole. The harness mirrors that in ResultMessage.
Since 0.28.0 the platform script command takes paths from the pipeline, as do the Win32 detection and requirement commands, so a folder of scripts is one line:
| |
A failed run carries a warning about what happens next, because the documentation and the device disagree on retries:
Learn says a failed script retries three times on the next three check-ins. On both test devices the total was three runs, at download counts 0, 1 and 2, and at the next fetch the agent logged has download count = 3, filtered the policy out and never ran it again. The retries only happen at a script policy fetch, which is an agent start or restart and otherwise every eight hours. The hourly Win32 check-ins in between fetch no script policy. A platform script that fails on its first run and depends on something arriving later has about sixteen hours to see it.
Timeouts and the gaps that remain
The default timeout is 300 seconds. The agent passes 1,800 to AgentExecutor for platform scripts and 3,600 for remediations, and -TimeoutSeconds takes either when you want the real number. In user mode a timeout kills the process tree with taskkill /T; in task mode it’s the run-id sweep shown above.
Standard input in user mode is an empty stream. Under the agent there’s a hidden console with nobody at it, so Read-Host waits for the timeout; in the harness it returns at once. That’s the one behavior I’d rather catch statically, and IslInteractiveCall does.
Starting it from PowerShell 7
In user mode, without -Context System or -Credential, the harness starts powershell.exe directly, and the child inherits the caller’s environment. From Windows PowerShell 5.1 that’s harmless. From PowerShell 7 in 0.26.0, the child got PowerShell 7’s PSModulePath, with PowerShell 7’s own module folders ahead of the Windows PowerShell ones, and a 5.1 process loaded PowerShell 7’s copies of in-box modules.
On my machine that child loaded Microsoft.PowerShell.Management and Microsoft.PowerShell.Utility 7.0.0.0 from the PowerShell 7 install, had no Cert: drive, and couldn’t load Get-AuthenticodeSignature or ConvertTo-SecureString. Started from 5.1, the same probe got the 3.x modules and Cert: worked. A certificate detection run from PowerShell 7 wrote “Cannot find drive” to stderr, exited 0 and came back “Without issues”, a result no device would give you. The agent’s SYSTEM module path has none of those PowerShell 7 folders.
PowerShell 7 resets the path itself when you run powershell.exe as a command from a pwsh prompt. It doesn’t when the process is started through System.Diagnostics.Process, which is how the harness starts one. The fix filters the path before the child starts:
| |
Only the three folders PowerShell 7 adds for itself come out. Anything else in the session’s path stays, in order, so a module folder you added on purpose still reaches the child. Under Windows PowerShell the function returns the path untouched, since $PSHOME\Modules there is the child’s own. With it in place, the certificate detection from PowerShell 7 found its three certificates and reported 3, the same as from 5.1. The scheduled-task paths start from the scheduler’s environment and were unchanged.
The fix shipped in 0.27.0. On 0.26.0, start the harness from Windows PowerShell 5.1 for user-mode runs.
The rest of the deviations are listed in the help of each command: user context is you unless you pass -Credential, and the parent process is cmd.exe or your own shell where the agent’s is AgentExecutor. None of those change an exit code or an output line in the runs I compared, and the result objects carry RunAs and Host so you can see what you tested against.
Invoke-IntuneDetectionTest, Invoke-IntuneRequirementTest and Invoke-IntuneWin32AppTest sit on the same Invoke-IslScriptRun and Invoke-IslProcess pair, with the Win32 agent’s stricter reading of stdout, stderr and exit codes on top. Their help pages are in docs/IntuneScriptLab in the repository.
The runs in this post were made with IntuneScriptLab 0.29.0.
Hunting down these changing patterns and looking for consistency is why this hasn’t reached v1.0.0 yet. And why I’d welcome feedback if you find something wrong.
