Repository navigation
fix(harness): a remediation that writes to stderr is a script error, whatever its exit code - #26
Conversation
…whatever its exit code Invoke-IntuneRemediationTest ran the post-detection and reported Recurred whenever the remediation exited 0, on the round-1 reading of the device's RemediationStatus codes. The device reads the remediation's stderr as well: a remediation that wrote a cmdlet error and exited 0 was reported as RemediationStatus 3, Graph remediationState scriptError, the error text attached, and no post-detection run. The same script with the error silenced ran the post-detection and was reported Recurred, and a detection that wrote an error and exited 0 was reported Without issues. Measured on the lab device in user context on 2026-10-07 with five one-off remediations, round 11, recorded in Findings.md and carried in Experiments.psd1 as REM-STDERR-EXIT0, REM-STDERR-SILENT and REM-DETECT-STDERR-EXIT0. The harness reports Failed for a remediation with anything on stderr, skips the post-detection and warns that the exit code was 0. The status rows in Findings carry the stderr clause. IslOutputIssue warns about Write-Error and an unguarded cmdlet in a remediation script, as it did for Win32 detection, and offers -ErrorAction Stop as the fix, since the error is a script error either way and Stop at least stops the script from claiming success. Help and the rule reference updated. Two facts about user-context policies from the same day are in the Findings section: a local account at the console is "no user logged on" to the agent, an Entra user without an Intune licence gets no policies, and Restart-Service does not restart the agent.
fadwen
left a comment
There was a problem hiding this comment.
Notes on the lines whose reason the diff does not show.
| $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) |
There was a problem hiding this comment.
Whitespace is not an error: a redirection can leave a stray line ending on stderr, and the agent's capture trims. Anything with text in it was a script error on the device.
| } | ||
| $probing = 'Get-Item', 'Get-ItemProperty', 'Get-ItemPropertyValue', 'Get-ChildItem', 'Get-Package', | ||
| 'Get-Service', 'Get-Process', 'Get-WmiObject', 'Get-CimInstance', 'Get-AppxPackage', | ||
| 'Set-ItemProperty', 'New-ItemProperty', 'Remove-Item', 'Remove-ItemProperty', 'Copy-Item', 'Move-Item' |
There was a problem hiding this comment.
The Win32 list plus the cmdlets a remediation uses to change things, which are the ones that fail on a missing target: the post's own Set-ItemProperty was the case measured.
| 'the Recurred the post-detection would have given. Use -ErrorAction Stop and let the ' + | ||
| 'failure be one, or check the target first') | ||
| Evidence = $remediationEvidence | ||
| Fix = @{ Replacement = $unguarded[0].Extent.Text + ' -ErrorAction Stop' } |
There was a problem hiding this comment.
Stop, where the Win32 rule offers SilentlyContinue: on a remediation the error is a script error whichever way, and Stop at least ends the script before it prints success and exits 0. SilentlyContinue would turn the run into a Recurred that hides the cause.
| Where-Object { -not (Test-IslCommandParameter -Command $_ -ParameterName 'ErrorAction') }) | ||
| $preferenceSet = Find-IslAstNode -Ast $ast -TypeName AssignmentStatementAst -Where { | ||
| param($node) $node.Left.Extent.Text -match '(?i)^\$ErrorActionPreference$' -and | ||
| $node.Right.Extent.Text -match '(?i)SilentlyContinue|Ignore|Stop' |
There was a problem hiding this comment.
A preference of Stop counts as a guard here, unlike in the Win32 block: a terminating error ends the remediation with exit 1, which is the honest Failed, not a success claim followed by a script error.
| Question = 'Remediation writes a cmdlet error to stderr and exits 0: Recurred, or a script error' | ||
| RunAs32Bit = $true | ||
| RunAsAccount = 'user' | ||
| Schedule = @{ Type = 'RunOnce'; DelayMinutes = -10080 } |
There was a problem hiding this comment.
A week in the past on purpose: the agent runs a run-once schedule that has passed at its next policy fetch, whatever the device clock says (VM 125 sits hours behind until an Entra user signs in). A time ahead waited for a clock that never arrived.
…ught Six lines over 115 characters in the new Experiments.psd1 entries: the registry path moves into a variable in the script bodies and three questions are shortened. The scripts are the ones that ran on the device, reworded only in the question text.
Summary
Invoke-IntuneRemediationTestran the post-detection and reported Recurred whenever the remediation exited 0, on the round-1 reading of the device'sRemediationStatuscodes. The device reads the remediation's stderr as well. Measured on the lab device on 2026-10-07, user context, five one-off remediations for a per-user registry key that is absent until written:RemediationStatusdetectionState/remediationStateSet-ItemPropertyon the missing key, no-ErrorAction,exit 0fail/scriptError-ErrorAction Stop(exit 1)fail/scriptError-ErrorAction SilentlyContinue(exit 0, nothing on stderr)fail/remediationFailedsuccess/skippedfail/successAgentExecutor logged
Powershell exit code is 0for the first row; the verdict came from stderr alone. The harness reported that row as Recurred up to 0.28.0. It now reports Failed, skips the post-detection and warns that the exit code was 0. A detection's stderr changes nothing, as the harness already said.Changes
Public/Invoke-IntuneRemediationTest.ps1. Anything but whitespace on the remediation's stderr is Failed with no post-detection, and a warning when the exit code was 0. Help: the status mapping and a paragraph on which script's stderr counts.Private/Rules/Find-IslOutputIssue.ps1. For a remediation script:Write-Erroris a warning, and an unguarded cmdlet (the Win32 list plus the cmdlets that write:Set-ItemProperty,New-ItemProperty,Remove-Item,Remove-ItemProperty,Copy-Item,Move-Item) is a warning with-ErrorAction Stopas its fix, unless$ErrorActionPreferenceis set to Stop, SilentlyContinue or Ignore. Stop rather than SilentlyContinue: the error is a script error either way, and Stop keeps the script from printing success first. Two rows indocs/Rules.md.Validation/Experiments.psd1. Round 11:REM-STDERR-EXIT0,REM-STDERR-SILENT,REM-DETECT-STDERR-EXIT0, user context, 32-bit, run once with the date in the past so the agent runs them at its next fetch. The kit's-GroupName ISL-ESP-Devicestargets the device whose signed-in user holds a licence.Validation/Findings.md. The two status rows gain the stderr clause; a new section records the five runs and two facts about user-context policies: a local account at the console is "no user logged on" to the agent, an Entra user without an Intune licence gets "0 script policies", andRestart-Service IntuneManagementExtensionrestarts nothing.Write-Error, the unguarded cmdlet and its fix, and silence for a guarded cmdlet, a preference and a detection script.Verification
docs/Rules.mdregenerated; help Markdown valid and the MAML rebuilt.ISL-POST-FEEDBACK-*onISLISLESP01, run once, and show the states in the table.Notes
scriptErrorfor "remediation script exit non-zero" and nothing for "exit 0 with stderr", because no round had run that case. The first draft of a blog post did, by accident.