Skip to content

fix(harness): a remediation that writes to stderr is a script error, whatever its exit code - #26

Merged
fadwen merged 3 commits into
mainfrom
fix/harness-remediation-stderr
Oct 7, 2026
Merged

fadwen merged 3 commits into
mainfrom
fix/harness-remediation-stderr

Conversation

@fadwen

@fadwen fadwen commented Oct 7, 2026

Copy link
Copy Markdown
Owner

Summary

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

Remediation script Device RemediationStatus Graph detectionState / remediationState Post-detection
Set-ItemProperty on the missing key, no -ErrorAction, exit 0 3 fail / scriptError not run
same with -ErrorAction Stop (exit 1) 3 fail / scriptError not run
same with -ErrorAction SilentlyContinue (exit 0, nothing on stderr) 2 fail / remediationFailed run, still failing
detection writes a cmdlet error to stderr and exits 0 4 success / skipped
key created first 1 fail / success run, passing

AgentExecutor logged Powershell exit code is 0 for 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-Error is 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 Stop as its fix, unless $ErrorActionPreference is 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 in docs/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-Devices targets 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", and Restart-Service IntuneManagementExtension restarts nothing.
  • Tests. Harness: stderr with exit 0 is Failed with no post-detection and the warning; whitespace on stderr is not an error. Rule: Write-Error, the unguarded cmdlet and its fix, and silence for a guarded cmdlet, a preference and a detection script.
  • Changelog. Under Unreleased, Fixed and Added.

Verification

  • Unit and integration suites pass on PowerShell 7.6.6; the harness, rule, repair and kit unit tests pass on Windows PowerShell 5.1 (89 of 89, 33 skipped for the lab). PSScriptAnalyzer (Error and Warning) is clean; no line over 115 characters; docs/Rules.md regenerated; help Markdown valid and the MAML rebuilt.
  • The five policies are still deployed in the dev tenant as ISL-POST-FEEDBACK-* on ISLISLESP01, run once, and show the states in the table.

Notes

  • The Graph side already said this in a different place: Findings row 93 listed scriptError for "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.

…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 fadwen left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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'

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

fadwen added 2 commits October 7, 2026 10:16
…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.
@fadwen
fadwen merged commit a87c25e into main Oct 7, 2026
4 checks passed
@fadwen
fadwen deleted the fix/harness-remediation-stderr branch October 7, 2026 17:50
@fadwen fadwen mentioned this pull request Oct 7, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant