With Microsoft closing most of its public Learn documentation repositories to community contributions by the end of 2026, and those pages leaning on AI more and more, I figured it was right to approach this with Zero Trust from the start and make sure everything this module claims is based on evidence, not on their documentation. I would’ve loved to run these in Hyper-V, but my homelab runs PVE, so I don’t have to worry about rearming licenses and the like.
Every finding IntuneScriptLab reports carries an Evidence string that ends in an experiment id, like REM-RETURN-EXIT or W32-DET-NOOUT. Most of those ids name a script that ran through Intune on a VM in my lab. This is the experiment behind the return 1 rule from the linting post, as it sits in Validation/Experiments.psd1:
| |
That remediation went out on 23 September, assigned hourly to a test device. While writing this post I asked the device what it had recorded since. At 17:28 UTC on 15 October, the probe file for REM-RETURN-EXIT held 168 records, every one of them from the detection. In three weeks the remediation script never ran once.
The validation kit lives in the Validation folder of the module’s repository. It doesn’t ship in the Gallery package, because it needs a tenant, a Proxmox host and devices you don’t mind breaking. This post is how it works.
Three VMs and a gold image
The lab is a Proxmox VE host (9.0.3 as I write this) with a sysprepped Windows 11 Enterprise LTSC 24H2 template and three clones of it. Each test device is a different way of ending up in Intune:
INTUNE-TEST-01 is Entra registered, INTUNE-TEST-02 is Entra joined, and INTUNE-TEST-03 went through a user-driven Autopilot run with an Enrollment Status Page. This is qm listsnapshot for the two that are running, with the tenant left out:
| |
New-IntuneLabVm.ps1 makes the clean-oobe snapshot. It clones the template, starts the clone and waits for the first boot to settle at the out-of-box experience. It checks for that through the QEMU guest agent:
0|True means the OOBE flag has cleared and Windows’ setup account has a session: the account screen is up. The first boot after sysprep reboots at least once on its way there, so the script polls every 20 seconds for up to 20 minutes.
Then it undoes something an unattend file may have done. An unattend with HideOnlineAccountScreens leaves OOBE offering only a local account, which is useless for an Entra join, and the values stay in the registry for every later boot:
After the reboot it waits for the account screen again and takes the snapshot. The join itself is left to a person, since it needs a user sign-in with MFA. One gotcha there: signing in from Settings, Access work or school, Connect only registers the device. “Join this device to Microsoft Entra ID” on the same page is the join. The README’s next step is a snapshot with memory after the join (qm snapshot 125 joined --vmstate 1), so the enrolled, signed-in state can come back with qm rollback after an experiment that breaks something.
The Autopilot device has its SMBIOS serial set on the VM, to ISL-ESP-01. The device name template ISL%SERIAL% then turned that serial into ISLISLESP01, since Autopilot strips the hyphens.
It took me more tries than I’d like to admit to get the template right, and I had to roll back a couple of times.
A probe in front of every script
Intune reports what it thinks happened. The kit needed a second witness that didn’t depend on Intune, so every experiment script is uploaded with Probe.ps1 in front of it. The probe’s Write-ProbeRecord builds one record per call, and the first line of each experiment body calls it:
| |
I’ve cut some fields from that excerpt; the full record also has the execution policy, language mode, TEMP, APPDATA, the console encodings, the culture and a NonAscii field. Each record goes as one JSON line into C:\ProgramData\IntuneScriptLab\<experiment>.jsonl on the device. This is the first one REM-RETURN-EXIT wrote, trimmed to the fields above and with the folder names shortened:
| |
The launch details in the overview post come from records like this one. The command line is the launch line, with -NoProfile -executionPolicy bypass -file and no -NonInteractive. ParentName and the parent’s command line show AgentExecutor.exe and the 3600-second timeout it passes. ScriptFirstBytes is 23 20 53 68, which is # Sh from the probe’s first comment line, so the file on disk has no BOM because I uploaded it without one. Experiments with Bom = $true show EF BB BF there instead.
The NonAscii field is the encoding test: a literal with German umlauts, an em dash and a check mark. It only survives if the agent’s PowerShell decoded the file as UTF-8. Without a BOM it came back as Grüße â€" ✓, the trap the linting post covers. One probe line answers the encoding question for every script the kit deploys, at no cost to the experiment.
A second function, Write-EspState, records what the Enrollment Status Page was doing when a script ran: the FirstSync values under the enrollment key, the apps the page was tracking, whether a user had a session and whether explorer.exe was running. That’s how round 8 could say which phase each script landed in.
Experiments as data
Experiments.psd1 is the whole catalog, as data. It has three lists, Remediations, PlatformScripts and Win32Apps, and as of 0.33.0 they hold 142 experiments: 42 remediations, 11 platform scripts and 89 Win32 apps. Every entry has a Name and a Question, and the rest of the keys say how to deploy it:
| Key | What it controls |
|---|---|
RunAs32Bit, RunAsAccount | Bitness and context. Left out when the question is what Graph defaults to |
Bom | Upload with a UTF-8 BOM; the default is without |
Schedule, DetectOnly | Remediations: run once, daily or hourly, and whether a remediation script is uploaded at all |
DetectionRules, RequirementRules, Requirement | Win32 file, registry, product code and script rules |
Filter, InstallContext, DependsOn, Supersedes, Intent | Win32 assignment and relationship shapes |
PadKB | Pads the script with comment lines, for the size-limit experiments |
The Question goes into the policy’s description in Intune, along with a SHA-256 of the exact bytes uploaded. ConvertTo-ScriptContent builds those bytes:
| |
The probe’s own record carries the SHA-256 of the file the agent ran, so a record can be matched to the upload byte for byte. That’s how I know the agent writes remediation scripts to IMECache\HealthScripts without adding or stripping a BOM.
75 of the 142 experiment names are cited somewhere in the module’s commands and rules.
A round, start to finish
Invoke-ValidationRound.ps1 drives a round with five actions:
| |
Prepare runs once per device. It creates the probe folder and grants BUILTIN\Users modify on it, so user-context probes can write next to SYSTEM ones, and then runs Fixtures.ps1 to put the files, registry values and MSI survey in place that the Win32 rule experiments look at. Round 8 shows what the grant prevents: a user-context script appending to a file SYSTEM had created got access denied, and the stderr turned a platform script into Fail.
Deploy connects to Graph with a certificate, creates a group called ISL-Validation-Devices, adds the test device to it, and creates every experiment as a policy named ISL-<Name>. A policy that already exists is skipped, so running Deploy again adds whatever is new in the catalog, and -Name with wildcards keeps a round to its own experiments. Every action supports -WhatIf.
Graph’s own defaults came out of Deploy before any device ran anything: a remediation created without runAs32Bit or runAsAccount is stored as 64-bit SYSTEM, where the portal’s toggles default to 32-bit. A later round’s lesson is written into Deploy as a comment:
| |
Win32 apps take more work. Every script-based app shares one .intunewin whose install.ps1 only writes a probe record, built with Microsoft’s IntuneWinAppUtil. Deploy downloads that tool when it’s missing and won’t run it unless its Authenticode signature is valid and Microsoft’s. A detection rule’s verdict is then read from whether the install ran: a rule the agent finds true leaves the app Installed with no install record, and one it finds false makes the agent run the install and end at 0x87D1041C, not detected after installation. The agent’s own log never prints the values it compared, so a probe record or its absence is the only way to see them.
Collect puts both witnesses in one JSON file under Results\. From Graph it takes the run states of every ISL- remediation and platform script, and for each Win32 app the DeviceInstallStatusByApp export job. Those jobs run one at a time. Ten submitted together used the whole deadline and two never finished, so -AppName keeps a collection to the apps a round cares about. From the device it takes every probe record, the agent’s registry result cache and filtered lines from AppWorkload.log, HealthScripts.log and the agent log for the round’s policy ids. -Since leaves out probe records older than a date. A device that has run hourly experiments for weeks has a lot of them.
Remove deletes everything with the ISL- prefix: apps, scripts, remediations, filters and the group.
Restarting the agent
Trigger restarts the Intune Management Extension so it fetches policy now. Through round 10 it did that with Restart-Service. In round 11 that call printed Running and restarted nothing: the stop had failed, the process kept its start time, and the next runner cycle was an hour away. Stopping the service’s process and then starting the service fetched the new assignments within ten minutes.
Trigger now does it that way, in one guest agent call, and prints the process start time from both sides:
| |
A restart that didn’t happen shows up as Before and After being the same. The log post uses the same check by hand.
A restart also has a cost when it does work. After a fetch, the agent queues each remediation run for five minutes later, and a restart before then throws the queued run away and adds about three minutes of start-up delay. So the rule in the kit’s README is to restart once and then wait.
The guest agent as a transport
None of the device-side steps open a console or a remote PowerShell session. Everything goes over SSH to the Proxmox host and through the QEMU guest agent into the VM, as SYSTEM, and the VM needs nothing beyond the agent the template already has.
Collect failed on 5 October with a JSON parse error in the middle of the device payload. The payload file on the device was fine. Chunk 154 of 179 had arrived with the right length and another chunk’s text.
The cause was how the agent hands back results. A command started through the guest agent returns a process id, and the result waits with the agent until a status call for that id collects it. A result nobody collects stays there. Windows reuses process ids quickly, and a status call returns the oldest result held under the id. Of 245 commands I started without collecting their results, 29 ids were used two or three times, and asking for one of them returned the first command’s output, then the second’s, then the third’s. qm guest exec leaves results behind whenever its own status poll times out while the command is still running.
Invoke-GuestPowerShell in GuestAgent.ps1 now starts every command without waiting and puts a marker of its own in front of it:
| |
The first line the command prints is its marker. A reply that doesn’t start with it belongs to some earlier command. Asking for it collected it, so the next reply under the same id is this command’s:
There was a second trap. A status call that times out on the host has still been answered by the agent, and if the command had finished, its result went with the reply nobody read. The next status call then says PID does not exist. In that case the function runs the command again, up to three times, so every snippet sent this way has to be safe to run twice. The detached runner Collect uses had the same problem one level up. A second copy found the output file held by the first, skipped the script and wrote its done marker at once, so the launch now sits behind a started marker file.
Moving files had its own costs. A guest exec call takes about six seconds, and its command line fails without an error above a few KB. The kit used to send scripts in 1,200-character base64 slices and read the payload back 100,000 characters per call: 179 calls and 25 minutes for one Collect. The agent also has file calls, which Proxmox exposes through pvesh. file-write takes 30,000 characters per call, so Send-GuestFile writes numbered base64 part files and has the VM join and hash them. file-read returns a file of up to 16 MB in one reply: 507,000 characters in 2 seconds. The device gzips the collect JSON before encoding it, and 13.4 MB of JSON from a month of hourly probe records packed to 380 KB. Both directions compare a SHA-256 the VM computed with the local one, so a transfer that goes wrong fails with both hashes in the message, before it can turn up later as a parse error.
For this post I used the same function, read-only, to ask VM 125 what its probe folder held. One call took 13.3 seconds to read and summarize 109 files.
Timing is part of the answer
A round has to wait long enough before deciding something didn’t happen. These are the numbers the kit’s README keeps, all measured on these VMs:
| Event | Observed |
|---|---|
| Group membership change to assignment on the device | 2 to 8 minutes |
| Win32 app after its assignment | By push, the first detection 6 seconds after the assign call |
| Policy fetch to first remediation run | 5 minutes, queued |
| Remediations | One at a time, about 15 seconds per policy |
| Script and remediation policy fetch without a restart | Every 8 hours |
| “Hourly” remediation cycles | 60 to 66 minutes apart, drifting with the runner’s poll |
| Remediation result to Graph | Up to an hour |
The one-at-a-time line has a consequence. REM-INSTALL-MODULE from round 7 calls Install-Module as SYSTEM, which hangs on the NuGet provider prompt until the agent kills it at the 60-minute timeout. It did that every hourly cycle afterwards, and in round 10 it held the runner while seven new detections waited 70 minutes behind it. Its assignment was removed on 6 October. The policy stays in the tenant, unassigned, so Deploy still skips it.
The tenant audit post has the reporting lag in detail: 65 minutes for a remediation, seconds for platform scripts and Win32 apps.
What the device kept recording
The probe files don’t stop when a round does. Every experiment that stayed assigned has kept running, and every run added a record. At 17:28 UTC on 15 October, VM 125 had 8,475 records in 109 files (16.6 MB) going back to 23 September, and VM 126 had 1,800 in 21 files going back to the Autopilot run on 28 September. Both numbers grow every hour.
Most of that is the round-1 remediations repeating on their hourly schedule, and one file overturned a finding. Round 9 created six remediations that differ only in their assignment. One of them, ASSIGN-INEX, is assigned to the device group as an include and to the same group as an exclusion. Graph accepted both and returned only the include afterwards. VM 125 resolved the policy, and the runner didn’t execute it in the 55 minutes I watched, so I wrote it up as the exclusion holding on the device.
At the same reading, ASSIGN-INEX.jsonl on VM 125 held 186 records: 124 detections and 62 remediations, all as SYSTEM. The first is stamped 11:07 on 28 September by the device’s clock, which ran seven hours behind UTC that day, a little after the window I watched, and the last at 17:28 UTC. The policy was never changed after the round: its lastModifiedDateTime equals its createdDateTime, and the tenant’s audit log holds only its create and assign events.
The service drops the exclusion, the include stands, and the policy runs on every device in the group. Assignment docs say an exclusion wins over an include, but here the exclusion is never stored to win anything. The round-9 row in Findings is corrected, Test-IntuneDeployedScript no longer cites ASSIGN-INEX, and the tenant audit post says the same now. A 55-minute watch missed it, and a probe file that kept writing caught it.
Tests for the kit
The kit has its own Pester suite under Validation/Tests/Unit, and it’s the only part of Validation that runs in CI. GraphRules.ps1, which builds the Graph rule objects and remediation schedules from experiment entries, is dot-sourced as it is. The driver can’t be: it has #Requires lines for PowerShell 7.6 and the Graph module, and it runs an action when dot-sourced. The suite lifts its functions out through the parser instead:
| |
ssh and Invoke-MgGraphRequest are stubbed and then mocked, and a fake Proxmox host answers from queued replies, so each guest agent failure above has a test of its own. Two of the test names:
On my desktop, with Pester 6.2.0, the suite ran 62 tests in 4.3 seconds, all passing.
Running a round yourself
The kit’s README has the full list. In short, you need PowerShell 7.6 with Microsoft.Graph.Authentication, an app registration with certificate auth and the device management, app and group permissions it lists, a Proxmox host you can reach over key-based SSH, a sysprepped Windows 11 template with the QEMU guest agent installed, and a test user licensed for remediations.
Use a dev tenant. Remove finds everything by its ISL- prefix, but until you run it the remediations are assigned hourly and keep running on whatever is in the group, as the 8,475 records on VM 125 show. The Results and Tools folders are git-ignored, because the raw results name the tenant’s users and devices and Tools holds a downloaded Microsoft binary.
Adding an experiment is an entry in Experiments.psd1 with a new name, then Deploy again. Keep the body Windows PowerShell 5.1 compatible unless 5.1 compatibility is the question, and start it with Write-ProbeRecord <Name> <phase>. Everything after that line is the behavior under test, and Findings.md is where its answer goes, next to what the documentation says.
The probe counts, snapshots and test run in this post came from the lab and my desktop on 15 October 2026, with the validation kit as of commit 55997ac on main (after IntuneScriptLab 0.33.0), Pester 6.2.0 and Proxmox VE 9.0.3.
