Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,15 @@ release notes.

## [Unreleased]

Nothing yet.
### Changed

- Validation kit: `Collect` moves its payload through the guest agent's file-read call in one
reply, gzipped on the device before base64, instead of 179 guest exec calls of 100,000
characters; scripts go in through the agent's file-write call, 30,000 characters per call
instead of 1,200 (`Send-GuestFile`, `Receive-GuestFile` in `GuestAgent.ps1`). `Collect -Since`
keeps only the probe records written from that time on. The round-7 experiment
`REM-INSTALL-MODULE`, which hangs for its 60-minute timeout every hour and holds the lab's
remediation runner, is no longer assigned.

## [0.27.0] - 2026-10-05

Expand Down
2 changes: 1 addition & 1 deletion Validation/Findings.md
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ device, the agent restarted once to fetch them.
|---|---|---|---|
| Execution policy | Scripts run regardless of the device's execution policy (PS) | AgentExecutor launches every script as `powershell.exe -NoProfile -executionPolicy bypass -file <script>`; inside a remediation `Get-ExecutionPolicy -List` read `MachinePolicy=Undefined;UserPolicy=Undefined;Process=Bypass;CurrentUser=Undefined;LocalMachine=Undefined` (REM-EXECPOLICY). A `Set-ExecutionPolicy` in a script changes nothing for that run; any scope but Process changes the device's policy as SYSTEM | ✅ |
| Module path under SYSTEM | Not documented | `$env:PSModulePath` in a SYSTEM remediation is `WindowsPowerShell\Modules;C:\Program Files\WindowsPowerShell\Modules;C:\WINDOWS\system32\WindowsPowerShell\v1.0\Modules`: the first entry is a **relative** path, because SYSTEM has no Documents folder to resolve, so a module installed with `-Scope CurrentUser` as SYSTEM lands nowhere useful. 90 modules were available on a plain Windows 11 device, the same list as `Get-Module -ListAvailable` under SYSTEM outside the agent (REM-PSMODULEPATH; the list is Get-IslInboxModule) | ⚠️ |
| `Install-Module` from a remediation | Not documented | **Hangs the queue.** `Find-Module` and `Install-Module -Scope CurrentUser -Force` in a SYSTEM detection script never returned: no NuGet provider on the device, no network connection from the process, 12 s of CPU in 20 minutes, 14 threads, stuck on the provider-bootstrap prompt that a non-interactive session cannot answer. Because remediations run one at a time, every remediation queued behind it waited too; the agent killed it at exactly 60 minutes (AgentExecutor.log `Error:Powershell script execution timed out. timeout = 3600 seconds`, agent log `exitCode = 2147483647`), Graph reports `detectionState scriptError`, and the queue moved on; the two remediations behind it ran an hour late (REM-INSTALL-MODULE) | ⚠️ |
| `Install-Module` from a remediation | Not documented | **Hangs the queue.** `Find-Module` and `Install-Module -Scope CurrentUser -Force` in a SYSTEM detection script never returned: no NuGet provider on the device, no network connection from the process, 12 s of CPU in 20 minutes, 14 threads, stuck on the provider-bootstrap prompt that a non-interactive session cannot answer. Because remediations run one at a time, every remediation queued behind it waited too; the agent killed it at exactly 60 minutes (AgentExecutor.log `Error:Powershell script execution timed out. timeout = 3600 seconds`, agent log `exitCode = 2147483647`), Graph reports `detectionState scriptError`, and the queue moved on; the two remediations behind it ran an hour late (REM-INSTALL-MODULE). It did the same every hourly cycle afterwards and held round 10's seven detections for 70 minutes, so its assignment was removed on 2026-10-06; the policy stays in the tenant, unassigned | ⚠️ |
| `#Requires -Modules` for a module the device lacks | The script does not run (PS docs on #Requires) | As documented, and what Intune makes of it: the detection exits 1 without running, stderr `The script 'detect.ps1' cannot be run because the following modules that are specified by the "#requires" statements of the script are missing: IslNoSuchModule` (`ScriptRequiresMissingModules`), so the remediation script **runs** (exit 0), the post-detection fails the same way, and Graph reports `detectionState fail, remediationState remediationFailed` with the error text in both detection outputs (REM-REQUIRES-MODULE) | ✅ |
| Script size | "Scripts must be less than 200 KB" (REM, PS) | Not enforced at 200 KB. Through the Graph API a remediation of 504 KB and a platform script of 660 KB were accepted; 512 KB and 680 KB were refused with a generic "An error has occurred" (no size in the message). On the device a 500 KB platform script ran (PS-SIZE-500KB, probe record at 00:31:49) and a 250 KB remediation ran and reported `big script ran` with `detectionState success` (REM-SIZE-250KB) | ❌ |

Expand Down
131 changes: 95 additions & 36 deletions Validation/GuestAgent.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -143,32 +143,98 @@ function Invoke-GuestPowerShell {
$output
}

function Read-GuestPayload {
function Send-GuestFile {
<#
.SYNOPSIS
Fetches a base64 text file from the VM in chunks and checks it against the VM's own hash.
Writes a file into the VM through the guest agent's file-write call and checks its hash.

.DESCRIPTION
A payload of megabytes returned in one reply makes the guest agent time out its own status
call, so it is read 100,000 characters at a time. A chunk that comes back short is asked
again. The whole text is then hashed and compared with the SHA-256 the VM computed over its
copy: a chunk with the right length and the wrong content is otherwise found only as a
parse error somewhere in the middle of the result, if at all.
The agent's file-write call takes the content as text on the host's command line, about
30,000 characters at a time here, against 1,200 per guest exec before (a 140 KB module zip
went over as five parts in under a minute, where guest exec needs about 6 seconds per part).
The bytes go as base64 in numbered part files, which a guest command joins, decodes and
removes; a part written twice replaces itself. The VM's SHA-256 of the written file is
compared with the local one.

.PARAMETER RemotePath
The file on the VM. ASCII text (base64).
Where the file lands on the VM.

.PARAMETER Size
Its length in characters, as the VM reported it.
.PARAMETER Bytes
The file's bytes.

.PARAMETER PartSize
Base64 characters per file-write call. Default 30000, which keeps the whole ssh command
line under the 32,767 characters Windows allows.

.EXAMPLE
Send-GuestFile -RemotePath 'C:\ProgramData\IntuneScriptLab\collect.ps1' -Bytes $bytes

The script is on the VM, byte for byte.

.OUTPUTS
None.
#>
[CmdletBinding()]
[OutputType([void])]
param(
[Parameter(Mandatory)]
[string]$RemotePath,

[Parameter(Mandatory)]
[byte[]]$Bytes,

[int]$PartSize = 30000
)

$null = Invoke-GuestPowerShell -Script ("`$null = New-Item -ItemType Directory -Path " +
"'$(Split-Path -Path $RemotePath -Parent)' -Force; " +
"Remove-Item -Path '$RemotePath.b64.*' -ErrorAction SilentlyContinue")
$b64 = [Convert]::ToBase64String($Bytes)
$index = 0
for ($offset = 0; $offset -lt $b64.Length; $offset += $PartSize) {
$part = $b64.Substring($offset, [Math]::Min($PartSize, $b64.Length - $offset))
$partPath = '{0}.b64.{1:D4}' -f $RemotePath, $index++
$command = "pvesh create /nodes/`$(hostname)/qemu/$VmId/agent/file-write " +
"--file '$partPath' --content '$part'"
$raw = ssh -o BatchMode=yes $ProxmoxHost $command 2>&1
if ($LASTEXITCODE -ne 0) {
throw "file-write of $partPath failed on ${ProxmoxHost}: $(($raw -join ' ').Trim())"
}
}
$join = "`$parts = Get-ChildItem -Path '$RemotePath.b64.*' | Sort-Object Name | " +
"ForEach-Object { (Get-Content -Path `$_.FullName -Raw).Trim() }; " +
"[IO.File]::WriteAllBytes('$RemotePath', [Convert]::FromBase64String((-join `$parts))); " +
"Remove-Item -Path '$RemotePath.b64.*'; (Get-FileHash -Path '$RemotePath' -Algorithm SHA256).Hash"
$remoteHash = "$(Invoke-GuestPowerShell -Script $join)".Trim()
$sha = [Security.Cryptography.SHA256]::Create()
try { $localHash = [BitConverter]::ToString($sha.ComputeHash($Bytes)) -replace '-', '' }
finally { $sha.Dispose() }
if ($remoteHash -ne $localHash) {
throw "$RemotePath did not arrive intact: the VM hashes it $remoteHash, the local bytes hash $localHash"
}
Write-Verbose "Sent $($Bytes.Length) bytes to $RemotePath in $index part(s)"
}

function Receive-GuestFile {
<#
.SYNOPSIS
Reads a text file from the VM in one guest agent call and checks it against the VM's hash.

.DESCRIPTION
The agent's file-read call returns a file of up to 16 MB in one reply; 507,000 characters
came back in 2 seconds, where reading the same text through guest exec took 179 calls of
100,000 characters. The text is hashed and compared with the SHA-256 the VM computed over
its copy, so a reply that is not the file is refused with both hashes rather than found
as a parse error later.

.PARAMETER RemotePath
The file on the VM. ASCII text, base64 as the collect script writes it.

.PARAMETER Sha256
The SHA-256 of its text as the VM computed it, in hex.

.PARAMETER ChunkSize
Characters per call. Default 100000.

.EXAMPLE
Read-GuestPayload -RemotePath 'C:\ProgramData\IntuneScriptLab\collect.b64' -Size 17838300 -Sha256 $hash
Receive-GuestFile -RemotePath 'C:\ProgramData\IntuneScriptLab\collect.b64' -Sha256 $hash

The file's text, or an error naming both hashes when it did not arrive intact.

Expand All @@ -182,36 +248,29 @@ function Read-GuestPayload {
[string]$RemotePath,

[Parameter(Mandatory)]
[int]$Size,

[Parameter(Mandatory)]
[string]$Sha256,

[int]$ChunkSize = 100000
[string]$Sha256
)

$parts = for ($offset = 0; $offset -lt $Size; $offset += $ChunkSize) {
$length = [Math]::Min($ChunkSize, $Size - $offset)
$read = "[IO.File]::ReadAllText('$RemotePath').Substring($offset, $length)"
# A reply without its output (the agent occasionally returns none for a large chunk) is asked again
$part = $null
for ($try = 1; $try -le 3 -and "$part".Trim().Length -ne $length; $try++) {
if ($try -gt 1) { Write-Warning "Payload chunk at $offset came back short; retrying"; Start-Sleep 5 }
$part = Invoke-GuestPowerShell -TimeoutSeconds 120 -Script $read
}
if ("$part".Trim().Length -ne $length) { throw "Payload chunk at $offset could not be read" }
"$part".Trim()
$command = "pvesh get /nodes/`$(hostname)/qemu/$VmId/agent/file-read --file '$RemotePath' --output-format json"
$raw = ssh -o BatchMode=yes $ProxmoxHost $command 2>&1
$text = ($raw -join "`n").Trim()
if ($LASTEXITCODE -ne 0 -or -not $text.StartsWith('{')) {
throw "file-read of $RemotePath failed on ${ProxmoxHost}: $text"
}
$reply = $text | ConvertFrom-Json
if ($reply.truncated) {
throw "$RemotePath is longer than the 16 MB the guest agent's file-read call returns"
}
$text = -join $parts
$content = "$($reply.content)"
$sha = [Security.Cryptography.SHA256]::Create()
try {
$digest = $sha.ComputeHash([Text.Encoding]::ASCII.GetBytes($text))
$digest = $sha.ComputeHash([Text.Encoding]::ASCII.GetBytes($content))
$actual = [BitConverter]::ToString($digest) -replace '-', ''
}
finally { $sha.Dispose() }
if ($actual -ne $Sha256) {
throw ("The payload from $RemotePath did not arrive intact: $($text.Length) characters with SHA-256 " +
"$actual, the VM's copy has $Sha256")
throw ("$RemotePath did not arrive intact: $($content.Length) characters with SHA-256 $actual, " +
"the VM's copy has $Sha256")
}
$text
$content
}
22 changes: 5 additions & 17 deletions Validation/Invoke-LabGuestScript.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -81,26 +81,14 @@ $ErrorActionPreference = 'Stop'

$remote = "C:\ProgramData\IntuneScriptLab\$RemoteName"
$content = Get-Content -Path (Resolve-Path -Path $ScriptPath) -Raw
$b64 = [Convert]::ToBase64String([Text.UTF8Encoding]::new($true).GetPreamble() +
[Text.Encoding]::UTF8.GetBytes($content))
$null = Invoke-GuestPowerShell -Script ("New-Item -ItemType Directory -Path 'C:\ProgramData\IntuneScriptLab' " +
"-Force | Out-Null; Remove-Item -Path '$remote.b64.*' -ErrorAction SilentlyContinue")
$index = 0
for ($offset = 0; $offset -lt $b64.Length; $offset += 1200) {
$part = $b64.Substring($offset, [Math]::Min(1200, $b64.Length - $offset))
$partPath = '{0}.b64.{1:D4}' -f $remote, $index++
$null = Invoke-GuestPowerShell -Script "Set-Content -Path '$partPath' -Value '$part' -NoNewline"
}
$deliveredMessage = "Delivered $ScriptPath to VM $VmId as $remote ($index parts)"
Write-Information -InformationAction Continue -MessageData $deliveredMessage

$decode = "`$parts = Get-ChildItem -Path '$remote.b64.*' | Sort-Object Name | " +
"ForEach-Object { Get-Content -Path `$_.FullName -Raw }; " +
"[IO.File]::WriteAllBytes('$remote', [Convert]::FromBase64String((-join `$parts)))"
$bytes = [Text.UTF8Encoding]::new($true).GetPreamble() + [Text.Encoding]::UTF8.GetBytes($content)
Send-GuestFile -RemotePath $remote -Bytes $bytes
Write-Information -InformationAction Continue -MessageData "Delivered $ScriptPath to VM $VmId as $remote"

# A parameter name (-AutoLogon) must reach the script bare, or it binds as a positional string;
# only values with spaces or quotes are quoted
$arguments = ($ArgumentList | ForEach-Object {
if ($_ -match '^-[A-Za-z]' -or $_ -notmatch "[\s']") { $_ } else { "'$($_ -replace "'", "''")'" }
}) -join ' '
Invoke-GuestPowerShell -TimeoutSeconds $TimeoutSeconds -Script (
"$decode; Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; & '$remote' $arguments")
"Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass -Force; & '$remote' $arguments")
Loading
Loading