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:

1
2
3
4
5
6
7
8
9
# IntuneScriptLab: Context=User
$key = 'HKCU:\Software\Microsoft\Siuf\Rules'
$value = (Get-ItemProperty -Path $key -ErrorAction SilentlyContinue).NumberOfSIUFInPeriod
if ($value -eq 0) {
    Write-Output "Feedback requests are off"
    exit 0
}
Write-Output "Feedback requests are on (value: $value)"
exit 1

The remediation writes the value and says so:

1
2
3
4
5
# IntuneScriptLab: Context=User
$key = 'HKCU:\Software\Microsoft\Siuf\Rules'
Set-ItemProperty -Path $key -Name NumberOfSIUFInPeriod -Value 0
Write-Output "Feedback requests turned off"
exit 0

Both read fine in a review. I ran the pair through the harness on a machine where the key had never been created:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
Status        : Failed
IntuneOutput  : Feedback requests are on (value: )
IntuneError   :
PreDetection  : exit 1 in 2.6 s
Remediation   : exit 0 in 2.1 s
PostDetection : skipped
Warnings      : {Remediation exited 0 but wrote to stderr: Intune reports the run as a script error
                (Failed, Graph scriptError) with the error text attached, and skips the post-detection.
                Silence the error or exit non-zero on purpose}
Architecture  : x86

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:

1
2
3
4
Warning IslOutputIssue line 3: Set-ItemProperty writes an error record to stderr when its target
is missing, and the script carries on to exit 0: Intune then reports a script error, not the
Recurred the post-detection would have given. Use -ErrorAction Stop and let the failure be one,
or check the target first

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

1
Invoke-IntuneRemediationTest -DetectionPath .\Detect.ps1 -RemediationPath .\Remediate.ps1 -Architecture x86

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:

 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
if ($pre.TimedOut) {
    $status = 'TimedOut'
}
elseif ($pre.ExitCode -eq 0) {
    $status = 'Without issues'
}
else {
    ...
        $remediation = Invoke-IslScriptRun -Path $RemediationPath -Phase 'remediate' @scriptRunSplat
        # Anything on the remediation's stderr is a script error to the agent, whatever the exit
        # code: RemediationStatus 3, Graph scriptError, no post-detection (REM-STDERR-EXIT0)
        $remediationStdErr = -not [string]::IsNullOrWhiteSpace($remediation.StdErr)
        if ($remediation.TimedOut) { $status = 'TimedOut' }
        elseif ($remediation.ExitCode -ne 0) { $status = 'Failed' }
        elseif ($remediationStdErr) {
            $status = 'Failed'
            $warnings.Add('Remediation exited 0 but wrote to stderr: ...')
        }
        else {
            $post = Invoke-IslScriptRun -Path $DetectionPath -Phase 'detect' @scriptRunSplat
            $status = if ($post.TimedOut) { 'TimedOut' }
            elseif ($post.ExitCode -eq 0) { 'Fixed' }
            else { 'Recurred' }
        }
}

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 scriptDevice RemediationStatusGraph detectionState / remediationStatePost-detection
as written above: exit 0, the error on stderr3fail / scriptErrornot run
-ErrorAction Stop (exit 1)3fail / scriptErrornot run
-ErrorAction SilentlyContinue (exit 0, nothing on stderr, key still missing)2fail / remediationFailedrun, still failing
the key created first1fail / successrun, passing
detection writes a cmdlet error to stderr and exits 04success / 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:

The Intune Remediations list for the five round 11 policies. Two show 1 under With issues and nothing else, one shows 1 under Without issues, one shows 1 under With issues and 1 under Issue fixed, and one shows 1 under With issues and 1 under Recurred.

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 statusDevice RemediationStatusGraph detectionState / remediationState
Without issues4success / skipped
Fixed1fail / success
Recurred2fail / remediationFailed
Failed3fail / 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Like IMECache: the script runs from a copy, so $PSScriptRoot is not the source folder
$runId = [guid]::NewGuid().ToString('N')
$cache = if ($Credential) {
    Join-Path -Path $env:ProgramData -ChildPath "IntuneScriptLab\Runs\$runId"
}
else {
    Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath "IntuneScriptLab\$runId"
}
$null = New-Item -ItemType Directory -Path $cache -Force
$scriptCopy = Join-Path -Path $cache -ChildPath "$Phase.ps1"
Copy-Item -LiteralPath $source -Destination $scriptCopy
1
2
3
4
5
6
7
8
$processSplat = @{
    FilePath         = $hostInfo.Path
    Arguments        = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptCopy`""
    WorkingDirectory = $WorkingDirectory
    WorkFolder       = $cache
    Context          = $Context
    TimeoutSeconds   = $TimeoutSeconds
}

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:

1
2
3
4
5
6
User=DESKTOP\jeff
Session=1 Interactive=True
Is64=False PSVersion=5.1.26100.9587
Cwd=C:\WINDOWS\System32
PSScriptRoot=C:\Users\jeff\AppData\Local\Temp\IntuneScriptLab\fb2fc593f1cd401da6508a27bfe49cb2
Temp=C:\Users\jeff\AppData\Local\Temp

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:

ValueHostWhere it exists
x86SysWOW64\WindowsPowerShell\v1.0\powershell.exex64 devices natively, ARM64 devices emulated
x64System32\WindowsPowerShell\v1.0\powershell.exex64 devices only
arm64System32\WindowsPowerShell\v1.0\powershell.exeARM64 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:

1
This is an X64 device; there is no ARM64 PowerShell host here. Use -Architecture x64 (or x86)

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:

1
2
3
4
5
6
7
$valueName = if ($Kind -eq 'ANSI') { 'ACP' } else { 'OEMCP' }
$codePage = if ($Kind -eq 'ANSI') { 1252 } else { 437 }
try {
    $key = 'HKLM:\SYSTEM\CurrentControlSet\Control\Nls\CodePage'
    $value = (Get-ItemProperty -Path $key -Name $valueName -ErrorAction Stop).$valueName
    if ($value -match '^\d+$') { $codePage = [int]$value }
}

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:

1
2
3
4
5
$intune = Get-IslIntuneOutput -StdOut $pre.StdOut -StdErr $pre.StdErr
if ($intune.DroppedLines -gt 0) { $warnings.Add("Detection wrote $($intune.DroppedLines + 1) lines; Intune " +
    "reports only the last one") }
if ($intune.OutputTruncated) { $warnings.Add('Detection output exceeds 2,048 characters; Intune keeps the ' +
    'last 2,048') }

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:

1
2
3
4
5
if ($Context -eq 'System') {
    $logon = 'ServiceAccount'
    $userName = 'NT AUTHORITY\SYSTEM'
    $principalSplat = @{ UserId = 'SYSTEM'; LogonType = 'ServiceAccount'; RunLevel = 'Highest' }
    $registerTaskSplat.Principal = New-ScheduledTaskPrincipal @principalSplat

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:

1
2
3
# Outer quotes are stripped by cmd /C; !ERRORLEVEL! needs /V:ON to expand after the command runs
$wrapper = ("/V:ON /C `"`"$FilePath`" $Arguments 1>`"$outFile`" 2>`"$errFile`" & echo !ERRORLEVEL! " +
    ">`"$exitFile`"`"")

/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:

1
2
3
4
5
6
7
8
9
# Anything the task started carries the run id in its command line
$cimInstanceSplat = @{
    ClassName   = 'Win32_Process'
    Filter      = "CommandLine LIKE '%$runId%'"
    ErrorAction = 'SilentlyContinue'
}
Get-CimInstance @cimInstanceSplat |
    Where-Object { $_.ProcessId -ne $PID } |
    ForEach-Object { & taskkill.exe /PID $_.ProcessId /T /F 2>&1 | Out-Null }

-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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$resolved = Resolve-IslAccount -Name $userName
$account = if ($userName -match '\\') { ($userName -split '\\')[-1] }
elseif ($userName -match '@') { ($userName -split '@')[0] }
else { $userName }
$sessions = @(Get-IslLogonSession | Where-Object {
        if ($resolved) { $_.Sid -eq $resolved.Sid } else { $_.UserName -eq $account }
    })
$logon = if ($LogonType -ne 'Auto') { $LogonType }
elseif ($sessions.Count -gt 0) { 'Interactive' }
else { 'Password' }

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:

1
2
3
4
5
$filter = "Name='explorer.exe' OR Name='sihost.exe'"
$shells = @(Get-CimInstance -ClassName Win32_Process -Filter $filter -ErrorAction SilentlyContinue)
foreach ($shell in ($shells | Sort-Object -Property SessionId, Name)) {
    $owner = Invoke-CimMethod -InputObject $shell -MethodName GetOwner -ErrorAction SilentlyContinue
    $sid = (Invoke-CimMethod -InputObject $shell -MethodName GetOwnerSid -ErrorAction SilentlyContinue).Sid

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:

1
2
3
4
5
6
$candidates = @($Name)
if ($Name -match '@' -and $Name -notmatch '\\') { $candidates += "AzureAD\$Name" }
foreach ($candidate in $candidates) {
    $account = ConvertTo-IslAccount -Name $candidate
    if ($account) { return $account }
}

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.

1
2
$cred = Import-Clixml C:\ProgramData\IntuneScriptLab\isl-user.cred.xml   # or Get-Credential
Invoke-IntuneRemediationTest -DetectionPath .\Detect.ps1 -RemediationPath .\Remediate.ps1 -Context User -Credential $cred

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:

PropertyAgent (round 1)Harness, interactive task
Identitythe signed-in userthe account passed in -Credential
Sessionthe console session, UserInteractive truethe console session, UserInteractive true
Working directoryC:\WINDOWS\system32C:\Windows\System32
Profilethe user’s USERPROFILE, TEMP, APPDATAthe account’s own profile folders
Command line64-bit powershell.exe -NoProfile -executionPolicy bypass -file <copy>, parent AgentExecutor.exethe same command line, parent cmd.exe
Integritynot recordedMedium, 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:

1
2
3
4
5
6
7
8
9
# The refusal does not always come with a code: on the lab device a stored-password
# task for an account without the batch logon right sits Ready, "has not run yet",
# with no error anywhere. Five seconds of that after Start-ScheduledTask is the same
# failure
$neverStarted = $info.LastTaskResult -eq 267011 -and $state -eq 'Ready'
if ($neverStarted -and (Get-Date) -gt $neverStartedAfter) {
    $launchFailure = [uint32]267011
    break
}

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:

1
Get-ChildItem .\PlatformScripts -Filter *.ps1 | Invoke-IntunePlatformScriptTest -Architecture x64

A failed run carries a warning about what happens next, because the documentation and the device disagree on retries:

1
2
3
4
5
RunState      : Failed
ExitCode      : 1
ResultMessage : proxy not set
Warnings      : {Intune runs a failed platform script again at its next script policy fetch (an agent start or restart, otherwise every 8 hours), three runs in
                total (initial plus two retries), then reports Failed for good}

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$documents = [Environment]::GetFolderPath('MyDocuments')
$coreOnly = @(
    Join-Path -Path $PSHOME -ChildPath 'Modules'
    if ($env:ProgramFiles) { Join-Path -Path $env:ProgramFiles -ChildPath 'PowerShell\Modules' }
    if ($documents) { Join-Path -Path $documents -ChildPath 'PowerShell\Modules' }
) | ForEach-Object { $_.TrimEnd('\') }

$separator = [System.IO.Path]::PathSeparator
$kept = foreach ($entry in ($ModulePath -split [regex]::Escape($separator))) {
    if ($entry -and $entry.TrimEnd('\') -notin $coreOnly) { $entry }
}
$kept -join $separator

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.