Skip to content

fix(install): detect Windows Defender AV block and emit specific guidance#1408

Merged
danielmeppiel merged 2 commits into
mainfrom
danielmeppiel/literate-lamp
May 19, 2026
Merged

fix(install): detect Windows Defender AV block and emit specific guidance#1408
danielmeppiel merged 2 commits into
mainfrom
danielmeppiel/literate-lamp

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

@danielmeppiel danielmeppiel commented May 19, 2026

Problem

The Windows installer (and apm self-update) fails on hosts where Windows Defender (or another real-time AV) flags the unsigned PyInstaller apm.exe as potentially unwanted software. The binary smoke test throws:

Program 'apm.exe' failed to run: Operation did not complete successfully because the file contains a virus or potentially unwanted software

This is HRESULT 0x800700E1 (ERROR_VIRUS_INFECTED). PR #1390 added Test-AccessDeniedError for AppLocker / App Control for Business denials (HRESULT 0x80070005), 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: add Test-AntivirusBlockError mirroring the 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:

    1. Add-MpPreference -ExclusionPath on the install root (elevated PowerShell).
    2. pip install --user apm-cli (avoids the binary entirely).
    3. Submit the binary to the Microsoft false-positive portal.

    Wired into the testFailure branch ahead of the AppLocker guidance check — the two error classes are distinct and must not be conflated.

  • scripts/windows/test-install-script.ps1: new Test-AntivirusDetector covering 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.ps1 functions. The new Test-AntivirusDetector runs in the existing windows-latest CI matrix entry alongside the rest of the install-script integration tests.

Not in scope

  • Signing the release binary. Proper long-term fix; tracked separately as a CI/release-infra change, not an installer change.
  • Auto-applying a Defender exclusion. Requires elevation and changes endpoint security posture — must remain an explicit user action.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

Copilot AI review requested due to automatic review settings May 19, 2026 21:09
…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>
@danielmeppiel danielmeppiel force-pushed the danielmeppiel/literate-lamp branch from d5cded7 to d9e1b74 Compare May 19, 2026 21:14
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-AntivirusBlockError and Write-AntivirusGuidance to 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

Comment thread install.ps1
Comment on lines +286 to +301
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'
)
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread install.ps1 Outdated
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."
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread install.ps1 Outdated
Comment on lines +343 to +345
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'"
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9040379. Now escapes via $escapedDir = $TargetInstallDir -replace "'", "''" before interpolation so the printed command stays valid for paths containing a single quote.

Comment thread scripts/windows/test-install-script.ps1 Outdated
`$results | ConvertTo-Json -Compress
"@

$tempScript = [System.IO.Path]::GetTempFileName() + ".ps1"
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Comment thread scripts/windows/test-install-script.ps1 Outdated
Comment on lines +192 to +198
`$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
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@danielmeppiel
Copy link
Copy Markdown
Collaborator Author

Heads-up on CI coverage for this PR: build-release.yml (which runs scripts/windows/test-install-script.ps1 on windows-latest) only triggers on tag push (v*), schedule, and workflow_dispatchnot on pull_request. So the new Test-AntivirusDetector test, and the rest of the Windows install-script suite, did not run against this PR. The matcher logic was validated locally with pwsh on macOS (9/9 cases including the cross-class 0x800704EC disambiguation), but real Windows coverage will only land when the next release tag is cut.

If desired, I can open a follow-up PR to add a pull_request trigger on changes to install.ps1 or scripts/windows/** so future installer regressions are caught at PR time.

@danielmeppiel danielmeppiel merged commit 9d1ae4e into main May 19, 2026
52 checks passed
@danielmeppiel danielmeppiel deleted the danielmeppiel/literate-lamp branch May 19, 2026 22:09
@danielmeppiel danielmeppiel mentioned this pull request May 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants