Say a Win32 app’s whole job is to remove something: a leftover file from an old agent, C:\ProgramData\OldAgent\agent.cfg. Installed means the file is gone, so the detection rule you’d write in the portal is a file rule with the operation “File or folder does not exist”. Graph accepts it too, as a win32LobAppFileSystemRule with operationType set to doesNotExist.

The agent can’t evaluate it. Round 5 of the validation kit deployed that rule twice. On a device where the file was missing, the app wasn’t detected, so the install ran and the app ended in 0x87D1041C, “not detected after installation complete”. On a device where the file was present, the detection ended Failed / NotComputed and the portal said Invalid detection rule or unable to parse detection rule, 0x87D30004. Neither device ever reported the app as installed. The registry version of the same operation works, which is what makes the file one easy to trust.

Test-IntuneWin32Rule reports both cases the way the device did:

1
2
3
4
5
Met    : False
Reason : C:\ProgramData\IslPost4\app\gone.txt is absent, but the agent evaluates a file DoesNotExist detection rule as not met even then (W32-FILE-NOTEXIST); use a registry DoesNotExist rule or a script instead

Met    : False
Reason : C:\ProgramData\IslPost4\app\data.bin exists, and the agent reports a file DoesNotExist detection rule on a present file as an invalid rule (0x87D30004, W32-FILE-NOTEXIST-FALSE)

The code is short, and the comment carries the decision behind it:

1
2
3
4
5
6
7
'DoesNotExist' {
    # The agent cannot evaluate this operation on a file: never met (W32-FILE-NOTEXIST,
    # W32-FILE-NOTEXIST-FALSE), so it is reported as it would be on the device
    $result.Reason = if ($exists) {
        ("$target exists, and the agent reports a file DoesNotExist $($RuleType.ToLower()) " +
            'rule on a present file as an invalid rule (0x87D30004, W32-FILE-NOTEXIST-FALSE)')
    }

Met stays false in both branches. A tool that evaluated the rule the way it reads would tell you the app is installed on a clean device, and you’d find out otherwise from a help desk ticket.

Win32 apps have more of these than remediations do, because there are more moving parts: a custom detection script or a set of file, registry and MSI rules, an optional requirement script, base requirements like OS release and free disk space, and relationships to other apps. This post covers the commands that check each of those locally. The runtime post covers the launch plumbing they share, so this one sticks to what the Win32 agent does differently.

Installed, according to a detection script

A Win32 custom detection script says “installed” in one way only: exit 0, something on stdout, nothing on stderr. Round 2 deployed ten apps with one detection script each and recorded which ones the agent called installed:

Detection scriptAgent’s verdict
Exit 0, writes installedDetected
Exit 0, writes nothingNot detected; the install runs, and the app ends in 0x87D1041C
Exit 0 and stdout, plus a Write-ErrorNot detected; AgentExecutor reports exit code -1
Exit 0, Write-Host 'installed'Detected: Write-Host counts as stdout
Exit 0, stdout, plus a Write-WarningDetected: a warning isn’t stderr
Exit 1 with stdoutNot detected
Unhandled throwExit 1, not detected

The Write-Error row is the one that catches people. A detection script that probes for an optional component with Get-ItemProperty, comes up empty on the first path and finds the second, has already written an error record by the time it prints “installed”. The linting post shows the static rule for that pattern, and since 0.28.0 the repair adds the -ErrorAction SilentlyContinue for you.

Invoke-IntuneDetectionTest runs a script and applies the same three conditions:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
if ($run.TimedOut) {
    $reasons.Add("Timed out after $TimeoutSeconds s; Intune kills the script at its " +
        '60-minute timeout and reports not detected')
}
elseif ($run.ExitCode -ne 0) {
    $reasons.Add("Exit code $($run.ExitCode): only exit 0 can mean installed")
}
if (-not $hasStdOut) { $reasons.Add('Nothing on stdout: exit 0 alone is "not detected"') }
if ($hasStdErr) {
    $reasons.Add('Output on stderr: any error output means "not detected" even with exit 0 and stdout')
}

Every condition that fails adds its own reason, so a script that exits 0, prints nothing and writes an error gets both explanations in one result, and you fix both in one pass.

1
2
Invoke-IntuneDetectionTest -Path .\Detect-App.ps1 -Context System
Get-ChildItem .\Win32 -Recurse -Filter Detect*.ps1 | Invoke-IntuneDetectionTest

A detection that reads a registry value with Get-ItemPropertyValue, finds no key, and still prints installed and exits 0, run as SYSTEM:

1
2
3
4
5
Detected : False
Reason   : Output on stderr: any error output means "not detected" even with exit 0 and stdout
ExitCode : 0
StdOut   : installed
RunAs    : NT AUTHORITY\SYSTEM (ServiceAccount)

Exit 0 and installed on stdout, and the agent would still call it not detected, because the missing key wrote an error record first.

On the device, detection scripts ran as SYSTEM in session 0, in the 64-bit host unless the rule’s runAs32Bit was set, with a 60-minute timeout, from a copy under C:\Program Files (x86)\Microsoft Intune Management Extension\Content\DetectionScripts\. Left out, -Architecture is the device’s own 64-bit host to match: x64 on an x64 device, arm64 on Windows on ARM. Before 0.30.0 it was always x64, which Windows on ARM refuses, since there’s no x64 PowerShell there. Its -Context defaults to User, meaning you, because SYSTEM needs an elevated session for the scheduled task; pass -Context System from an elevated prompt when the script reads anything that differs between your account and SYSTEM.

Signed scripts

The Win32 app page has a toggle to enforce a script signature check. With it on, the agent refused to run an unsigned detection script at all: no probe record, AgentExecutor exit 1, and EnforceSignatureCheck: 1 ... applicationDetected: False in AppWorkload.log. The app counted as not detected, the install ran, and the second detection was refused the same way. -EnforceSignatureCheck checks the signature before anything runs:

1
2
3
4
5
6
7
8
if ($EnforceSignatureCheck) {
    $signature = Get-AuthenticodeSignature -FilePath (Resolve-Path -LiteralPath $Path).ProviderPath
    $signatureStatus = "$($signature.Status)"
    if ($signature.Status -ne 'Valid') {
        $reasons.Add("Signature status ${signatureStatus}: with the signature check enforced the agent " +
            'does not run the script and reports not detected (exit 1 from AgentExecutor)')
    }
}

The install script in that experiment was unsigned too, and it ran anyway: the toggle didn’t stop it.

Requirement scripts compare everything they print

A requirement script decides whether the app applies to the device at all. You give the rule an output data type, an operator and a value, and the agent compares the script’s output to it. The docs say the agent reads stdout. Round 4 deployed seventeen apps to find out which part of stdout, and the answer is all of it, minus the final line break.

Compare-IslRequirementOutput encodes that in one line before anything else:

1
2
3
# Only the final line break is removed, CRLF from Write-Output or the bare LF Write-Host ends
# with (both met the rule on the device); "ok   " and "first`r`nok" both failed
$output = $StdOut -replace '\r?\n\z', ''

I ran the comparison against the outputs round 4 tested, with the rule set to String Equal ok unless the case says otherwise:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
Case                      Met Reason
----                      --- ------
Write-Output ok          True Output 'ok' meets String Equal 'ok'
Write-Host ok (bare LF)  True Output 'ok' meets String Equal 'ok'
OK, other case           True Output 'OK' meets String Equal 'ok'
trailing spaces         False Output 'ok   ' does not meet String Equal 'ok'
log line first          False Output 'checking
                              ok' does not meet String Equal 'ok'
version 2.10.0           True Output '2.10.0' meets Version GreaterThanOrEqual '2.9.0'
same, as a string       False Output '2.10.0' does not meet String GreaterThanOrEqual '2.9.0'
integer five            False Output 'five' is not an integer, so the rule fails
ok plus stderr          False Output on stderr: the rule fails even with exit 0 and matching stdout

Each row matches what the device did. String comparisons ignore case (W32-REQ-CASE). A version rule compares versions, so 2.10.0 is newer than 2.9.0, and the same values compared as strings come out the other way, which is a good reason to pick the data type carefully. An output that doesn’t parse as the rule’s type fails the rule, and the portal reports that case differently: Applicability 1 with no details text, where an ordinary unmet script rule shows “PowerShell script requirement rule is not met.” and Applicability 1008.

The log line row is the easiest one to write by accident. A requirement script that announces what it’s checking before it prints the value fails every comparison. The static rule catches it before you deploy:

1
2
3
4
5
# IntuneScriptLab: ScriptType=Win32Requirement
$build = [Environment]::OSVersion.Version.Build
Write-Host "Checking build $build"
if ($build -ge 26100) { Write-Output 'ok' } else { Write-Output 'old' }
exit 0
1
2
3
RuleName       Severity Line Message
--------       -------- ---- -------
IslOutputIssue Warning     4 3 statements write to the console (Write-Host included): the rule compares the whole output, so a second line means no match. Write exactly one value

At runtime, Invoke-IntuneRequirementTest runs the script the way the agent does and applies the rule:

1
2
Invoke-IntuneRequirementTest -Path .\Requirement.ps1 -OutputType String -Operator Equal -Value ok
Invoke-IntuneRequirementTest -Path .\Get-AgentVersion.ps1 -OutputType Version -Operator GreaterThanOrEqual -Value 2.9.0

The result’s Applicable and Reason come from the same comparison shown above, and Output is the exact string the agent would have compared.

Two details about when and how requirement scripts run aren’t in the docs. The detection runs first: the requirement only runs after the app comes back “not detected”, then the detection runs again, then the install. No requirement script ran before a “not detected” in that round. And a requirement script set to run as the signed-in user ran as that user on the Entra registered device, in the user’s session, which user-context platform scripts didn’t do on that device.

File, registry and MSI rules

Most Win32 apps don’t need a detection script; a file, registry or MSI product code rule does the job. Round 5 deployed forty apps against fixtures that put known files and registry values in place, and read each rule’s verdict from whether the install ran. The agent’s log only says applicationDetectedByCurrentRule: True/False, without the values it compared.

The results that matter for writing rules:

RuleOn the device
File versionA version compare: a file at 10.0.26100.x met greaterThanOrEqual 9.0
File sizeWhole MiB, rounded down: a 2 MiB file met equal 2 and failed greaterThan 2
%ProgramFiles% with “check 32-bit on 64-bit” offC:\Program Files, even though the agent is a 32-bit process
Registry stringCase-insensitive, like requirement strings
Registry integerA REG_SZ holding 42 is parsed and compared as 42
Registry “check 32-bit on 64-bit”Off reads the 64-bit view; on reads WOW6432Node
MSI product versionA version compare: 110.0.2 met greaterThanOrEqual 99.0.0
Several detection rulesAll of them must be met
File does not existNot evaluated (the opening of this post)

Test-IntuneWin32Rule evaluates each kind against the local machine, with parameters named after the portal’s fields:

1
2
3
Test-IntuneWin32Rule -Path '%ProgramFiles%\Contoso' -FileOrFolderName agent.exe -FileOperation Version -Operator GreaterThanOrEqual -Value 2.0
Test-IntuneWin32Rule -KeyPath 'HKEY_LOCAL_MACHINE\SOFTWARE\Contoso' -ValueName Version -RegistryOperation Version -Operator GreaterThanOrEqual -Value 2.0
Test-IntuneWin32Rule -ProductCode '{11111111-2222-3333-4444-555555555555}'

The registry branch opens the hive with an explicit view, so the result doesn’t depend on whether you’re in a 32-bit or 64-bit PowerShell:

1
2
3
$view = if ($Check32BitOn64System -and [Environment]::Is64BitOperatingSystem) { 'Registry32' }
elseif ([Environment]::Is64BitOperatingSystem) { 'Registry64' }
else { 'Default' }

It also takes a rule as a hashtable, in the shape Graph returns it. If you pull an app’s rules from deviceAppManagement/mobileApps, you can feed them straight in. I gave it a Graph-style file rule against a test file of exactly 2.5 MiB:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
$graphRule = @{
    '@odata.type'        = '#microsoft.graph.win32LobAppFileSystemRule'
    ruleType             = 'detection'
    path                 = 'C:\ProgramData\IslPost4\app'
    fileOrFolderName     = 'data.bin'
    check32BitOn64System = $false
    operationType        = 'sizeInMB'
    operator             = 'greaterThanOrEqual'
    comparisonValue      = '2'
}
Test-IntuneWin32Rule -Rule $graphRule
1
2
3
4
5
6
7
Met       : True
Kind      : File
Operation : sizeInMB
Operator  : greaterThanOrEqual
Value     : 2
Actual    : 2
Reason    : Size 2621440 bytes is 2 MiB rounded down: '2' meets Integer greaterThanOrEqual '2'

The converter maps operationType, operator, comparisonValue, check32BitOn64System, keyPath, valueName, productCode and productVersion onto the command’s parameters, and it hands a win32LobAppPowerShellScriptRule back with a pointer to Invoke-IntuneDetectionTest, since a script rule is run, not evaluated.

Base requirements and their codes

The portal’s base requirements (architecture, minimum OS release, free disk space, memory, logical processors, CPU speed) each produce their own “not applicable” message and their own code in the device’s ComplianceStateMessage. Rounds 4 to 6 collected them:

Requirement not metPortal detailsDevice Applicability
ArchitectureDevice architecture (e.g. x86/amd64) is not applicable for the application.1000
Free disk spaceAvailable disk space on the target device is less than the configured minimum.1001
MemoryAmount of RAM on the target device is less than the configured minimum.1003
Logical processorsCount of logical processors on the target device is less than the configured minimum.1004
File requirement ruleFile system requirement rule is not met.1006
Registry requirement ruleRegistry requirement rule is not met.1007
Script requirement rulePowerShell script requirement rule is not met.1008

Test-IntuneWin32Requirement reads the local device’s facts and checks each requirement you pass, returning the first failure’s text and code:

1
Test-IntuneWin32Requirement -Architecture x64 -MinimumWindowsRelease Windows11_24H2 -MinimumFreeDiskSpaceMB 2048 -MinimumMemoryMB 8192

Each check carries the device’s wording:

1
2
3
4
5
6
7
8
$checkSplat = @{
    Requirement = 'MinimumFreeDiskSpaceMB'
    Met         = $device.FreeDiskSpaceMB -ge $MinimumFreeDiskSpaceMB
    Actual      = $device.FreeDiskSpaceMB
    Expected    = $MinimumFreeDiskSpaceMB
    Code        = 1001
    Details     = 'Available disk space on the target device is less than the configured minimum.'
}

Two of the requirements didn’t fail on any test device: the minimum OS release (the test device met it) and CPU speed. For those the command leaves the code empty and says the portal text wasn’t observed.

The detection ran for every app before its applicability verdict, including the ones that weren’t going to apply. A detection script that’s slow or noisy runs even on devices where the app ends up not applicable.

An app for ARM64 devices only, checked on an x64 device:

1
2
3
4
5
Applicable    : False
Reason        : Architecture: x64 against arm64; Intune reports Not applicable
Applicability : 1000
Details       : Device architecture (e.g. x86/amd64) is not applicable for the application.
Checks        : Architecture: not met; MinimumMemoryMB: met

One app, end to end

Invoke-IntuneWin32AppTest puts the pieces together into the flow the agent runs for an assignment: detect, and when the app isn’t there, install from the content folder and detect again.

1
2
3
4
5
6
7
8
9
$appSplat = @{
    DetectionPath  = '.\Win32\Contoso\Detect-App.ps1'
    DetectionRule  = @{ Type = 'Registry'; KeyPath = 'HKEY_LOCAL_MACHINE\SOFTWARE\Contoso'; ValueName = 'Version'
                        OperationType = 'Version'; Operator = 'GreaterThanOrEqual'; Value = '2.0' }
    ContentPath    = '.\Win32\Contoso\content'
    InstallCommand = 'install.cmd'
    Context        = 'System'
}
Invoke-IntuneWin32AppTest @appSplat

A detection script and rules can be combined, and all of them have to report installed, as on the device. The install command runs from a copy of the content folder, like the agent’s C:\WINDOWS\IMECache\<appId>_<version>\, through the 32-bit cmd.exe:

1
2
$cmdFolder = if ([Environment]::Is64BitOperatingSystem) { 'SysWOW64' } else { 'System32' }
$cmd = Join-Path -Path (Join-Path -Path $env:WINDIR -ChildPath $cmdFolder) -ChildPath 'cmd.exe'

The bitness is the part to know. The agent’s own process is 32-bit, and round 2’s install command found that powershell.exe in an install command line started the 32-bit host, with Is64BitProcess=False. An install script that writes HKLM:\SOFTWARE\Contoso lands in WOW6432Node, and a 64-bit detection reading HKLM:\SOFTWARE\Contoso doesn’t find it. The command warns whenever it sees powershell on the install or uninstall command line:

1
powershell.exe in the install command runs the 32-bit host (the agent is a 32-bit process); HKLM:\SOFTWARE and Program Files are redirected there

A powershell.exe call inside the batch file the command names runs 32-bit just the same. Until 0.30.0 that case got no warning. Now the command reads the .cmd or .bat and names the line. I ran an app whose install command is just install.cmd, with powershell.exe on its second line:

1
2
3
4
Status       : Installed after install
Warnings     : {powershell.exe in install.cmd (line 2), which the install command runs through the 32-bit cmd.exe, is the 32-bit
               host (the agent is a 32-bit process); HKLM:\SOFTWARE and Program Files are redirected there}
Architecture : x64

The install.ps1 it called recorded Is64BitProcess as False. This app installed because its detection reads a marker file, where the redirection doesn’t reach.

0.30.0 also starts the install without NoDefaultCurrentDirectoryInExePath, a per-user shell setting that stops cmd.exe from finding a bare install.cmd in its working folder. The agent’s cmd.exe runs without your profile, so on a machine with the setting, the harness was failing installs the agent would have run.

The install’s exit code is mapped through the same return codes the portal defaults to: 0 and 1707 success, 3010 soft reboot, 1641 hard reboot, 1618 retry. -ReturnCodes takes your own table when the app’s installer uses others. A post-install detection that still says “not detected” ends in Not detected after install, with a warning naming 0x87D1041C and the retry at the next check-in.

-Intent Uninstall runs the uninstall side of round 5’s W32-UNINSTALL: detected, uninstall command, detection again, and Uninstalled once the detection stops saying installed. A detection that still reports installed after the uninstall leaves the app “installed” in Intune’s eyes, and the result says so.

I ran a small app through it as SYSTEM on an x64 device. Its install command runs an install.ps1 through powershell.exe to write HKLM:\SOFTWARE\IslPost4\Version, and a 64-bit detection script reads the value back:

1
2
3
4
5
6
7
Status   : Not detected after install
Warnings : {powershell.exe in the install command runs the 32-bit host (the agent is a 32-bit process); HKLM:\SOFTWARE and Program Files are redirected there,
           Post-install detection: Exit code 1: only exit 0 can mean installed; Nothing on stdout: exit 0 alone is "not detected". Intune reports 0x87D1041C
           and retries the install at the next check-in}
RunAs    : NT AUTHORITY\SYSTEM

Value landed in WOW6432Node: True

The install exited 0 and wrote its value; the value is under WOW6432Node, the 64-bit detection never sees it, and the app would loop through install and 0x87D1041C on every check-in.

Dependencies and supersedence

Round 6 deployed app pairs with each relationship type and recorded the order of every detection, install and uninstall on the joined device.

An autoInstall dependency ran in this order: the parent’s detection, the child’s detection, download and install, then the parent’s detection, download and install. The child, which wasn’t assigned to anything, showed no status in the portal at all. The agent logged why: Not sending status update ... because the app does not have available, required, or uninstall intent. A detect dependency whose child was absent stopped the parent with “1 or more dependent apps are configured to not automatically install.” and the parent’s install didn’t run.

Supersedence had a surprise in the update type. The new app installed, and the old app stayed installed, marker file and all, with the new app reporting “Superseded applications are detected.” The replace type did what its name says: the old app’s uninstall command ran first, then the new app’s install, and the old app reported “App was removed in order to install a superseding app.”

-DependsOn and -Supersedes take hashtables with the related app’s detection, content and command, and the flow follows the device’s order:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$appSplat = @{
    DetectionPath  = '.\NewAgent\Detect.ps1'
    ContentPath    = '.\NewAgent\content'
    InstallCommand = 'install.cmd'
    DependsOn      = @{ Name = 'VC++ runtime'; DetectionPath = '.\Vcrt\Detect.ps1'; ContentPath = '.\Vcrt\content'
                        InstallCommand = 'vc_redist.x64.exe /install /quiet /norestart'; Type = 'autoInstall' }
    Supersedes     = @{ Name = 'Old agent'; DetectionPath = '.\OldAgent\Detect.ps1'; ContentPath = '.\OldAgent\content'
                        UninstallCommand = 'uninstall.cmd'; Type = 'replace' }
}
Invoke-IntuneWin32AppTest @appSplat

A dependency that ends in anything other than installed stops the parent, as on the device:

1
2
3
4
5
6
7
if ($childStatus -notin 'Installed', 'Installed after install') {
    $status = 'Not installed (dependency)'
    $warnings.Add("Dependency $name ($type): $childStatus. The agent does not run this app's " +
        'install; an absent detect-only dependency reports Not installed with "1 or more ' +
        'dependent apps are configured to not automatically install." (W32-DEPD-PARENT)')
    break
}

The result object lists each related app with its own status (Installed, Installed after install, Uninstalled, Installed (left in place by update)), so a replace target that survived its uninstall shows up in the result next to the new app.

User install context

The Win32 app page lets you set the install behavior to User. Round 6 assigned such an app to a device group, and it never installed on either test device. The agent logged that the app “will not be evaluated as it has user install context and this is a userless check-in”, and the portal showed Not applicable with no details, Applicability 1011. A device-group assignment didn’t produce the user check-in a user-context app needs, on either join type.

-InstallContext User doesn’t change how the harness runs anything. It adds the warning, because the mistake lives in the assignment, and no local run can show you an assignment:

1
Install behavior User: an assignment to a device group never installs the app. The agent logs "user install context and this is a userless check-in" and reports Not applicable (Applicability 1011); assign the app to users

Where the harness simplifies

A few parts of the device’s flow aren’t in the local one.

The agent runs each rule three times per flow: before applicability, after the content download, and after the install. The harness detects before and after the install, since your machine doesn’t change between the first two passes. It also doesn’t run the requirement script inside Invoke-IntuneWin32AppTest; Invoke-IntuneRequirementTest and Test-IntuneWin32Requirement cover applicability on their own.

The timeouts are shorter by default: 300 seconds for detection scripts where the agent allows 60 minutes, and 600 seconds for the install where the agent allows 60 minutes too. -TimeoutSeconds and -InstallTimeoutSeconds take the real numbers when you need them.

Re-evaluation is out of reach. Every app in round 6 was detected again eight to nine hours after its first pass, the not-applicable ones included, and a local run is one pass. If the question is what happens on a device tomorrow, the agent log readers answer it from the device’s own log, and they get a post of their own.

The rows quoted here, with the experiment behind each, are in the Win32 sections of Validation/Findings.md. The runs in this post were made with IntuneScriptLab 0.29.0, apart from the install.cmd run, which used 0.30.0.

If you want to reproduce the rule, detection, base requirement and whole-app output yourself, Win32AppChecks.ps1 in the companion repo runs them from an elevated prompt and removes everything it creates.