fix(install): detect Windows Defender AV block and emit specific guidance#1408
Conversation
…ance The Windows installer (and 'apm self-update') failed on hosts where Defender or another real-time scanner flags the unsigned PyInstaller apm.exe as potentially unwanted software (HRESULT 0x800700E1). The binary smoke test threw 'Operation did not complete successfully because the file contains a virus or potentially unwanted software', which Test-AccessDeniedError did not match, so the installer fell through to a generic 'failed to run' message and a pip fallback that itself died on hosts running an unsupported Python (e.g. 3.14). Changes: - install.ps1: add Test-AntivirusBlockError mirroring the existing Test-AccessDeniedError contract. Matches the Defender PUA message, the generic 'contains a virus' / 'potentially unwanted software' phrasing, and HRESULTs 0x800700E1 and 0x800704EC. - install.ps1: add Write-AntivirusGuidance with three actionable options - Add-MpPreference -ExclusionPath on the install root, pip --user fallback, and the Microsoft false-positive submission URL. Wired into the testFailure branch ahead of the AppLocker guidance check; the two error classes are distinct. - docs/installation.md: add a troubleshooting subsection mirroring the existing AppLocker/WDAC one, documenting the same three recovery options. - scripts/windows/test-install-script.ps1: add Test-AntivirusDetector covering real Defender output, generic PUA text, both HRESULTs, the cross-class case (access-denied must not be classified as AV), and empty/benign strings. Cases verified locally against the install.ps1 functions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
d5cded7 to
d9e1b74
Compare
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Improves the Windows installer’s failure handling by detecting antivirus/Defender blocks (PUA/virus) and emitting targeted recovery guidance, plus adds documentation and automated tests to validate the new detector behavior.
Changes:
- Add
Test-AntivirusBlockErrorandWrite-AntivirusGuidanceto emit AV-specific remediation steps. - Route binary smoke-test failures to AV guidance before AppLocker/WDAC guidance.
- Add a dedicated PowerShell integration test that exercises real-world Defender/PUA strings and HRESULTs.
Show a summary per file
| File | Description |
|---|---|
| scripts/windows/test-install-script.ps1 | Adds CI coverage for the AV-block detector and cross-class disambiguation vs access-denied. |
| install.ps1 | Implements AV block detection and prints actionable remediation guidance. |
Copilot's findings
- Files reviewed: 2/2 changed files
- Comments generated: 5
| function Test-AntivirusBlockError { | ||
| # Defender / 3rd-party AV real-time protection blocks CreateProcess on | ||
| # binaries it flags with HRESULT 0x800700E1 (ERROR_VIRUS_INFECTED) or | ||
| # 0x800704EC (ERROR_VIRUS_DELETED). PowerShell surfaces these as | ||
| # "Operation did not complete successfully because the file contains a | ||
| # virus or potentially unwanted software". Our PyInstaller-built apm.exe | ||
| # is unsigned, which routinely trips false-positive heuristics. | ||
| param([string]$Text) | ||
| if (-not $Text) { return $false } | ||
| return ( | ||
| $Text -match 'contains a virus' -or | ||
| $Text -match 'potentially unwanted software' -or | ||
| $Text -match '0x800700E1' -or | ||
| $Text -match '0x800704EC' | ||
| ) | ||
| } |
There was a problem hiding this comment.
Confirmed and fixed in 9040379. 0x800704EC is ERROR_ACCESS_DISABLED_BY_POLICY (Win32 1260), a Group Policy / SRP block — not AV. ERROR_VIRUS_DELETED is Win32 226 = 0x800700E2. Dropped 0x800704EC from Test-AntivirusBlockError, added the correct 0x800700E2, and routed 0x800704EC plus the standard 'blocked by group policy' message into Test-AccessDeniedError where it belongs. Added a cross-class disambiguation test so we cannot regress this.
| Write-Host "Windows Defender (or another real-time scanner) flagged the binary as" | ||
| Write-Host "potentially unwanted software. The apm.exe release is built with" | ||
| Write-Host "PyInstaller and is currently unsigned, which routinely trips false-" | ||
| Write-Host "positive heuristics. The file is not actually malicious." |
There was a problem hiding this comment.
Agreed. Removed the unconditional 'not actually malicious' claim, replaced with conditional framing ('most blocks of apm.exe are false positives, but you should verify integrity before excluding it'), and added an explicit step before any exclusion advice: verify the SHA256 against the published .sha256 sidecar. Fixed in 9040379.
| Write-Host " 1. Add a Defender exclusion for the install directory (run in an" | ||
| Write-Host " elevated PowerShell, then rerun this installer):" | ||
| Write-Host " Add-MpPreference -ExclusionPath '$TargetInstallDir'" |
There was a problem hiding this comment.
Fixed in 9040379. Now escapes via $escapedDir = $TargetInstallDir -replace "'", "''" before interpolation so the printed command stays valid for paths containing a single quote.
| `$results | ConvertTo-Json -Compress | ||
| "@ | ||
|
|
||
| $tempScript = [System.IO.Path]::GetTempFileName() + ".ps1" |
There was a problem hiding this comment.
Fixed in 9040379. Replaced GetTempFileName() + '.ps1' with [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName() + '.ps1') in both spots (Test-AntivirusDetector and the original SHA256 fallback test on line 86, which had the same bug).
| `$results | ConvertTo-Json -Compress | ||
| "@ | ||
|
|
||
| $tempScript = [System.IO.Path]::GetTempFileName() + ".ps1" | ||
| try { | ||
| Set-Content -Path $tempScript -Value $childScript -Encoding UTF8 | ||
| $json = & pwsh -NoProfile -NonInteractive -File $tempScript 2>&1 | Select-Object -Last 1 |
There was a problem hiding this comment.
Fixed in 9040379. Switched to explicit ---APM-JSON-BEGIN--- / ---APM-JSON-END--- sentinels emitted by the child script; the parent now slices lines between them and joins, so any profile warnings, formatter output, or extra whitespace from pwsh cannot corrupt the parsed JSON.
Address review feedback on PR #1408: 1. CRITICAL: 0x800704EC is ERROR_ACCESS_DISABLED_BY_POLICY (GPO/SRP), NOT ERROR_VIRUS_DELETED. The correct deleted-by-AV HRESULT is 0x800700E2. Misclassifying a policy block as AV would prompt users to add a Defender exclusion to fix what is actually a Group Policy rule -- net regression vs pre-PR generic handler. - Test-AntivirusBlockError: drop 0x800704EC, add 0x800700E2. - Test-AccessDeniedError: add 0x800704EC and the standard SRP/GPO 'blocked by group policy' message string. These belong in the AppControl/AppLocker guidance bucket. 2. Soften 'not actually malicious' framing. In a real supply-chain compromise this code path would still fire; an unconditional safety claim is poor security hygiene. Reword conditionally and tell users to verify the published .sha256 sidecar BEFORE excluding the binary. 3. Single-quote-escape $TargetInstallDir in the printed Add-MpPreference command so the suggestion stays valid for paths containing a single quote. 4. Stop leaking GetTempFileName() zero-byte companions in the test harness. Use GetRandomFileName under $env:TEMP for both .ps1 temp files in scripts/windows/test-install-script.ps1. 5. Replace brittle 'Select-Object -Last 1' JSON parsing with explicit ---APM-JSON-BEGIN---/---APM-JSON-END--- sentinels so child-pwsh profile output cannot corrupt the parsed JSON. 6. Expand Test-AntivirusDetector cases to cover both corrected AV HRESULTs (0x800700E1, 0x800700E2) and the GPO/SRP cross-class disambiguation (0x800704EC must route to AccessDenied, not AV). Validated locally with pwsh: 9/9 cross-class detector cases pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Heads-up on CI coverage for this PR: If desired, I can open a follow-up PR to add a |
Problem
The Windows installer (and
apm self-update) fails on hosts where Windows Defender (or another real-time AV) flags the unsigned PyInstallerapm.exeas potentially unwanted software. The binary smoke test throws:This is HRESULT
0x800700E1(ERROR_VIRUS_INFECTED). PR #1390 addedTest-AccessDeniedErrorfor AppLocker / App Control for Business denials (HRESULT0x80070005), but did not cover this distinct AV error class. The installer therefore fell through to a generic "failed to run" message and a pip fallback that itself died on hosts running an unsupported Python (e.g. 3.14), leaving the user stuck with no actionable guidance.Changes
install.ps1: addTest-AntivirusBlockErrormirroring theTest-AccessDeniedErrorcontract. Matches the Defender PUA message, the genericcontains a virus/potentially unwanted softwarephrasing, and HRESULTs0x800700E1and0x800704EC.install.ps1: addWrite-AntivirusGuidancewith three actionable options:Add-MpPreference -ExclusionPathon the install root (elevated PowerShell).pip install --user apm-cli(avoids the binary entirely).Wired into the
testFailurebranch ahead of the AppLocker guidance check — the two error classes are distinct and must not be conflated.scripts/windows/test-install-script.ps1: newTest-AntivirusDetectorcovering real Defender output, generic PUA text, both HRESULTs, cross-class disambiguation (access-denied must NOT be classified as AV, and vice versa), and empty/benign strings.Validation
All 7 detector cases pass locally against the
install.ps1functions. The newTest-AntivirusDetectorruns in the existing windows-latest CI matrix entry alongside the rest of the install-script integration tests.Not in scope
Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com