I went looking for a new module to build after my TestEnvironment module had a reasonable number of providers. I wanted something useful that targets a live tenant, not just test environments, and something I could write about here. My first list was all tenant assessment: an Intune assignment resolver, a license waste finder, a Conditional Access documenter, an app registration hygiene report. I crossed every one of them off, because I’d already seen a module for each on the PowerShell Gallery or scrolling past on LinkedIn.

What I hadn’t seen was a way to test an Intune script before Intune gets it.

I remember when new Teams came out, and there were essentially three versions of Teams installed on a PC (personal, classic, new) to confuse the user. My detections and removals worked fine on my test machine but kept running into issues when Intune ran them. Finding the difference was frustrating.

If you’ve written remediations, you know the loop. You write a detection script, run it in your own console, see the exit code you wanted, and upload it. Then you wait. The agent fetches script policy on its own schedule, the run gets queued behind everything else, and an hour or more later the portal says “Without issues” on a device you know has the problem. You open the agent’s logs, find the run, and start guessing which of the differences between your console and the agent’s launch was the one that got you.

IntuneScriptLab came out of that loop. It checks a script for the mistakes Intune turns into wrong portal states, runs it locally the way the Intune Management Extension runs it, gives you Pester assertions for your own suites, reads the agent’s logs after the fact, and checks what your tenant has deployed against what’s in git. It’s on the PowerShell Gallery at 0.27.0, the source is at fadwen/IntuneScriptLab, and this post starts a series. This one covers why the module exists, how it decides what’s true, and where each command fits. The posts after it each take one part and go deep.

Three kinds of script, one agent

Intune runs PowerShell on Windows through a single agent, the Intune Management Extension, and it hands that agent three kinds of script.

Remediations come as a pair: a detection script whose exit code decides whether a remediation script runs, followed by the detection again to see whether the fix held. Platform scripts (the portal just calls them Scripts) run once and report success or failure. Win32 apps can carry a custom detection script, which decides whether the app counts as installed, and a requirement script, whose output decides whether the app applies to the device at all.

Each kind has its own rules for what an exit code means, which output Intune keeps, which account the script runs as and which PowerShell host starts it. Some of those rules are on Microsoft Learn. Plenty aren’t, and a few of the documented ones don’t match what a device does.

Under all of that, every script gets the same launch. The agent’s AgentExecutor process starts Windows PowerShell 5.1 with this command line, recorded on my test devices:

1
powershell.exe -NoProfile -executionPolicy bypass -file <script>

A surprising number of failures fall out of that one line. It’s Windows PowerShell 5.1, so a ternary or a ?? is a parse error and the script never starts. There’s no -NonInteractive, so a Read-Host doesn’t fail fast; it sits there until the 30 or 60 minute timeout. The working directory is C:\WINDOWS\system32, so .\config.json points somewhere you didn’t mean. When the script runs as SYSTEM it’s in session 0 with the systemprofile’s HKCU:, which is nobody’s registry hive you care about.

None of that shows up when you test at your own prompt, elevated, in PowerShell 7, from the folder the script lives in.

Why the usual tools stop short

I looked for something that already did this before writing it, and the closest tools each cover one slice.

PSScriptAnalyzer knows PowerShell. It’ll tell you about Write-Host, positional parameters and plain-text passwords, and it does that well. It has no idea a script is about to become a Win32 detection rule, so it can’t tell you that a Write-Error there flips the app to “not detected” even when the script exits 0 and printed the right line. The rule depends on what the agent does with the streams, and that knowledge lives outside the language.

Pester can run a script and check its exit code. That gets you part of the way. What it can’t do on its own is launch the script the way the agent does (5.1, the right bitness, system32 as the working directory, SYSTEM in session 0) or turn three exit codes into the status the portal would show. You’d end up rebuilding the harness inside every test file.

The remaining option is the real one: deploy to a test device and wait. I did a lot of that for this module, and it’s how the evidence got collected, but it’s measured in hours per question. A remediation is queued five minutes after the agent fetches policy, policy is fetched at agent start and otherwise every eight hours, and the run state can take an hour to reach Graph after the device reports. That’s a fine way to answer “what does Intune do?” once. It’s a terrible inner loop for writing a script.

I understand the latest Intune changes have shortened the wait, especially for new app installs, but if you’re changing an existing script, it’s likely to still take a while. Not a fan of telling the help desk or InfoSec, “OK, I’ve updated it. It’ll roll through the fleet randomly over the next 8 hours.”

IntuneScriptLab sits in that gap. The knowledge that came out of the slow loop is encoded once, so the fast loop at your desk can use it.

Evidence before rules

The decision I’d make again is that the module doesn’t encode a behavior until a device has shown it.

The repository holds a validation kit. Across ten rounds I deployed small probe scripts as remediations, platform scripts and Win32 apps to two Windows 11 VMs on Proxmox, one Entra joined and one only Entra registered, and recorded what the agent did with each. Every probe starts with a shared header that writes the account, bitness, PowerShell version, command line, parent process, session, working directory and a non-ASCII test string to a JSON file on the device. The record doesn’t depend on what Intune reports back, so when the portal and the device disagree I can see both.

The results live in Validation/Findings.md, one table per area, with what Learn says next to what the device did and a mark for confirmed, contradicted or undocumented. A few rows from it:

BehaviorMicrosoft LearnObserved on the device
Which detection exit runs the remediationOnly exit 1Any non-zero exit; 2 and -1 both ran it
Platform script retries3 retries on the next 3 check-insThree runs in total, at the agent’s policy fetches, then never again
Script sizeUnder 200 KBA 250 KB remediation and a 500 KB platform script ran; Graph refused a 512 KB remediation and a 680 KB platform script
Platform scripts on Entra registered devicesRegistered devices don’t receive themSYSTEM-context scripts ran; user-context ones were downloaded and skipped

The registered-device row surprised me most. The agent logs This is not AADJ/HAADJ device, skip user context for <policyId> and moves on, after downloading the policy. Learn’s wording is too strict for SYSTEM scripts and says nothing about why user scripts vanish.

Every finding the module produces carries an Evidence string ending in experiment IDs like REM-EXIT-2 or W32-DET-NOOUT. Those are entries in Validation/Experiments.psd1, and each entry holds the script body that ran on the device. When a finding tells you something about your script, you can open the experiment and read the probe behind it.

This is what the first of those IDs points at:

1
2
3
4
5
6
7
8
@{
    Name         = 'REM-EXIT-2'
    Question     = 'Detection exit 2'
    RunAs32Bit   = $false
    RunAsAccount = 'system'
    Detection    = 'Write-ProbeRecord REM-EXIT-2 detection; Write-Output "exit two"; exit 2'
    Remediation  = 'Write-ProbeRecord REM-EXIT-2 remediation; exit 0'
}

A question, the settings Intune got, and a script small enough to read in one glance. The remediation’s probe record showed up on both devices, and that settled it.

Where each command fits

The module’s commands line up with the stages a script goes through.

StageCommandsWhat you get back
Before you deployTest-IntuneScript, Repair-IntuneScript, Get-IntuneAnalyzerRulePath, Export-IntuneFindingSarifFindings with line numbers and evidence, the mechanical fixes applied, the same rules inside PSScriptAnalyzer and GitHub code scanning
Running it locallyInvoke-IntuneRemediationTest, Invoke-IntuneDetectionTest, Invoke-IntuneRequirementTest, Invoke-IntunePlatformScriptTest, Invoke-IntuneWin32AppTestThe status the portal would show: Fixed, Recurred, Failed, not detected, not applicable
Win32 rules and targetingTest-IntuneWin32Rule, Test-IntuneWin32Requirement, Test-IntuneAssignmentFilterFile, registry and MSI rules, base requirements and filter rules evaluated against this device
Inside your own testsShould-HaveIntuneStatus, Should-BeIntuneDetected and five morePester 6.2 assertions whose failure message carries the diagnosis
Against the tenantTest-IntuneDeployedScript, Compare-IntuneDeployedScript, Get-IntuneScriptHealthThe rules run on what’s deployed, drift against a git checkout, one health line per policy
On a device afterwardGet-IntuneAgentLog, Get-IntuneAgentTimeline, Export-IntuneAgentDiagnosticThe agent’s CMTrace logs as objects, one timeline per policy, a zip for a ticket

Two choices shape how all of that behaves. The manifest has RequiredModules = @(). Static analysis runs on Windows PowerShell 5.1 or PowerShell 7 on any OS with nothing else installed, and the tenant commands use the Microsoft Graph session you’ve already connected with Connect-MgGraph without depending on the Graph SDK. The second choice is that the tenant commands only read. Only one call in the module is a POST: it asks Graph for a report export job, because the health report needs one for app install counts; no policy, assignment or group is ever created, changed or deleted. The validation kit that does write to a tenant lives in the repository and stays out of the Gallery package.

The runtime harness needs Windows, because it launches the same in-box powershell.exe the agent launches. It covers x86 and x64 hosts on x64 Windows and x86 and ARM64 hosts on Windows on ARM.

Installing it

1
2
3
4
5
# From the PowerShell Gallery
Install-PSResource -Name IntuneScriptLab

# On Windows PowerShell 5.1 without PSResourceGet
Install-Module -Name IntuneScriptLab -Scope CurrentUser

What each layer needs, from the README:

LayerNeeds
Static analysisWindows PowerShell 5.1 or PowerShell 7, any OS
Runtime harnessWindows with the in-box Windows PowerShell 5.1
-Context System and -CredentialAn elevated session, because both go through a scheduled task
Pester assertionsPester 6.2 or later
Tenant commandsA Connect-MgGraph session with read scopes

A first run

For a first look I took the shape of one of Microsoft’s own sample detection scripts, swapped the certificate store for one any machine has, and dropped it in a folder called Remediations\StaleCerts:

1
2
3
4
5
6
7
8
$results = @(Get-ChildItem -Path Cert:\CurrentUser\Root | Select-Object -First 3)
if ($results) {
    Write-Host "Match"
    Return $results.count
    exit 1
}
Write-Host "No_Match"
exit 0

Read it the way you’d review it: certificates found, print “Match”, exit 1, remediation runs. The static rules disagree. This is Test-IntuneScript pointed at the folder, trimmed to the detection script’s findings:

1
2
3
4
5
6
7
8
9
RuleName          Severity    Line Message
--------          --------    ---- -------
IslAssumedContext Information    0 Analyzed as Detection (inferred from folder and file name), System context, x86: the portal defaults where nothing said
                                   otherwise; a deployment through the Graph API or IaC gets 64-bit SYSTEM. Pass -ScriptType/-Context/-Architecture or add a '#
                                   IntuneScriptLab:' directive if that is wrong
IslExitCodeIssue  Error          4 return at script scope ends the script with exit 0; any exit 1 after it never runs, so the remediation never triggers. Use
                                   exit 1 directly
IslOutputIssue    Warning        7 Write-Host is the last thing written, so its text is what Intune reports instead of your summary. Write the summary line
                                   last, with Write-Output

And this is the runtime harness running the pair, on the same machine, with the certificates present:

1
Invoke-IntuneRemediationTest -DetectionPath .\Remediations\StaleCerts\Detect.ps1 -RemediationPath .\Remediations\StaleCerts\Remediate.ps1
1
2
3
4
5
6
7
8
Status        : Without issues
IntuneOutput  : 3
IntuneError   :
PreDetection  : exit 0 in 1.2 s
Remediation   : skipped
PostDetection : skipped
Warnings      : {Detection wrote 2 lines; Intune reports only the last one}
Architecture  : x86

The detection found three certificates and reported “Without issues”, with 3 as its output. The remediation never ran. In a tenant that’s a policy reporting a clean fleet while every device it was written for still has the problem. Post two takes the return and the reported line apart. The static finding and the runtime result agree, and both trace back to experiments you can read.

I hit this writing a remediation for a Windows 10 to 11 upgrade, for the stragglers who weren’t listening to the update rings. It kept giving me a clean bill of health, saying everything was on 11 when it clearly wasn’t. It’s frustrating to have to say, “But it worked on my PC.”

Type, context and architecture

Most rules only make sense once the module knows what kind of script it’s looking at, which account will run it and in which host. A Write-Error is harmless in a remediation and fatal in a Win32 detection. HKLM:\SOFTWARE is fine in a 64-bit host and redirected to WOW6432Node in a 32-bit one.

You can pass all three as parameters, put a directive comment in the script, or let the module infer them from the file and folder names:

1
# IntuneScriptLab: ScriptType=Detection Context=System Architecture=x64

Inference follows the portal’s defaults: remediations and platform scripts in the 32-bit host, remediations as SYSTEM, platform scripts as the signed-in user, Win32 detection and requirement scripts in 64-bit. That’s where the IslAssumedContext note in the first run came from. It tells you what was assumed, because a wrong guess quietly skips whole rule sets.

The assumption has a catch I didn’t expect going in. When a script is created through Graph, or through anything built on Graph, and the bitness and account are left out, Graph stores runAs32Bit=false and runAsAccount=system. The same script deployed from the portal with default toggles runs 32-bit, and as a platform script it runs as the user. Two deployments of one file can behave differently, which is why the note says so, and why the tenant commands read the policy’s own settings.

Where the local run differs from the agent

The harness reproduces the launch closely, but a few gaps can’t be closed from your own session, and the module says so in its help.

User context runs as whoever is running the harness. The agent runs user-context scripts as the signed-in user, in that user’s session; -Credential gets you close by running as another account through a scheduled task, inside its session when it has one. SYSTEM context is exact, through a one-shot scheduled task, and needs elevation.

Standard input is an empty stream. A Read-Host returns at once in the harness where it would hang under the agent, so the hang is the static rules’ job (IslInteractiveCall).

Default timeouts are five minutes. The agent allows 30 minutes for platform scripts and 60 for remediations and Win32 scripts, and nobody wants to wait an hour for a local test, so -TimeoutSeconds is there when you do.

On Windows on ARM there’s no x64 PowerShell host at all, only native ARM64 and emulated x86. -Architecture x64 is refused there, which is also what the agent has to work with.

One gap is already closed: user-mode runs started from PowerShell 7 used to hand the child PowerShell 7’s module path, and it loaded the wrong copies of in-box modules. 0.27.0 fixes it, and the runtime post has the measurements.

Checking the module against itself

A tool whose whole pitch is “this is what really happens” had better not overclaim. Before I started this series, I went through every statement the module makes about itself, in the README, the command help, the about topic, the rule reference, the examples and the changelog, and checked each one against the code and by running it.

More of it was wrong than I’d have guessed. -Settings never reached the tenant pre-flight or the drift compare, because a body-level $settings hashtable overwrote the parameter (PowerShell variable names aren’t case-sensitive). -Id on its own selected every policy, since -Name defaults to * and the match was name or id. Repair-IntuneScript -WhatIf on a folder returned nothing, because the folder was enumerated with ForEach-Object -MemberName, which honors -WhatIf. The encoding fix rewrote ANSI files as UTF-8 with every non-ASCII character replaced by U+FFFD.

Each of those is in the changelog with what broke and why. I’d rather you read that list than find the bugs yourself.

Writing this series was a second pass of the same kind, and it found more. The harness loaded the wrong modules when started from PowerShell 7, -Credential couldn’t find an Entra account’s session, Repair-IntuneScript could hide the mistake it was run on, and one rule cited a parse error for calls that aren’t parse errors. Those fixes, and a tenth validation round to measure the cases I’d been guessing at, came out of the drafts. The posts describe the module after them.

The series

PostCovers
Testing Intune Scripts Before Intune DoesThis overview
Linting Intune Scripts With Rules Measured on Real DevicesTest-IntuneScript, the rules, directives, suppressions, the settings file, Repair-IntuneScript
Running Intune Scripts Locally the Way the Agent DoesThe remediation and platform script harness, SYSTEM, other accounts, ARM64
Win32 appsDetection and requirement scripts, file, registry and MSI rules, dependencies, supersedence
Assignment filtersTest-IntuneAssignmentFilter and what the service accepts and matches
The agent’s logsGet-IntuneAgentLog, timelines, the Enrollment Status Page, the diagnostic zip
The tenant sideThe deployed-script pre-flight, drift against git, the health report
Intune Rules Inside PSScriptAnalyzerThe PSScriptAnalyzer wrapper, settings files, suppressions and the -Severity trap
Intune scripts in CIThe GitHub Actions gate, SARIF and the Pester assertions
How the evidence was collectedThe validation kit and its ten rounds

Get it

The module is IntuneScriptLab on the PowerShell Gallery, MIT licensed, with the source, the help, the generated rule reference and the validation kit at fadwen/IntuneScriptLab. Get-Help about_IntuneScriptLab gives you the conceptual overview from inside a session, and every command’s help opens its page on GitHub with -Online.

The rules are only as good as the experiments behind them. If a finding fires on something your devices tolerate, the evidence string names the experiment I based it on, and I’d like to see the script that disagrees with it.