fix(install): survive AppLocker/WDAC by staging release before binary test (#1389)#1390
Merged
danielmeppiel merged 5 commits intoMay 19, 2026
Merged
Conversation
… test Closes #1389. The Windows installer (and `apm self-update`) failed on hosts with AppLocker or App Control for Business (WDAC) policies because the binary smoke test invoked apm.exe from %TEMP%, which is user-writable and therefore denied by default application-control rules. The OS returned HRESULT 0x80070005 (Access is denied), the installer fell back to pip, and pip itself failed on Python 3.14 (out of supported range) — leaving the user stuck. Changes: - install.ps1: refactor extract/test/install into stage-then-test-then- promote. The bundle is moved to %LOCALAPPDATA%\Programs\apm\releases <tag>.new-<guid> BEFORE apm.exe --version runs, so the test runs from the same allow-listed per-user install root that the shim will keep pointing at. On success the staging dir is atomically renamed into place; on failure the existing release is preserved. - install.ps1: add Get-Sha256Hex helper that tries Get-FileHash, then Import-Module Microsoft.PowerShell.Utility + Get-FileHash, then falls back to System.Security.Cryptography.SHA256 over a FileStream. This unblocks checksum verification on hardened hosts where $PSModuleAutoLoadingPreference='None' or a JEA-style restricted session prevents Get-FileHash from autoloading. - install.ps1: detect Access-Denied / 0x80070005 from the --version test and emit specific AppLocker/WDAC guidance (allow-list rule, APM_TEMP_DIR override, pip fallback) instead of silently falling back to pip. - scripts/windows/test-install-script.ps1: new Windows-only integration test covering the SHA256 .NET fallback, the move-then-test ordering, and a full end-to-end install of a pinned release into an isolated prefix with assertions on shim contents and temp-dir cleanup. - .github/workflows/build-release.yml: run the new test in the windows-latest matrix entry, immediately after the binary build. - docs/getting-started/installation.md: new troubleshooting subsection for AppLocker / App Control for Business with allow-list guidance and the APM_TEMP_DIR escape hatch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
This PR fixes Windows installation failures on AppLocker/WDAC-managed hosts by ensuring the binary smoke test runs from the final install tree (not %TEMP%), adding a SHA256 hashing fallback when Get-FileHash is unavailable, and improving troubleshooting guidance and CI validation.
Changes:
- Stage extracted releases into the final allow-listable install root before running
apm.exe --version, then promote with rollback. - Add
Get-Sha256Hexwith a .NET SHA256 fallback and surface AppLocker/WDAC-specific guidance on access-denied failures. - Add a Windows integration test script and wire it into the release workflow; document the AppLocker/WDAC troubleshooting steps.
Show a summary per file
| File | Description |
|---|---|
install.ps1 |
Stages release before smoke-testing, adds SHA256 fallback, and improves Access Denied guidance for AppLocker/WDAC. |
scripts/windows/test-install-script.ps1 |
Adds Windows end-to-end/integration coverage for the new staging and hashing behavior. |
.github/workflows/build-release.yml |
Runs the new Windows install integration test in CI. |
docs/src/content/docs/getting-started/installation.md |
Documents troubleshooting for AppLocker/WDAC “Access is denied” installs. |
Copilot's findings
- Files reviewed: 4/4 changed files
- Comments generated: 4
Comment on lines
+653
to
+654
| # Promote: replace any existing release atomically (rename the old one | ||
| # aside first so concurrent shim invocations can't see a missing dir). |
| try { | ||
| Move-Item -Path $packageDir -Destination $stagingDir -Force | ||
| } catch { | ||
| Write-ErrorText "Failed to stage release at ${stagingDir}: $_" |
Comment on lines
+101
to
+111
| function Test-MoveThenTestOrdering { | ||
| Write-Step "Test 2: install.ps1 moves bundle out of temp before running binary test" | ||
|
|
||
| $content = Get-Content $InstallScript -Raw | ||
|
|
||
| $stageIdx = $content.IndexOf("Move-Item -Path `$packageDir -Destination `$stagingDir") | ||
| $testIdx = $content.IndexOf("& `$stagedExe --version") | ||
|
|
||
| Assert-True ($stageIdx -gt 0) "Found staging Move-Item in install.ps1" | ||
| Assert-True ($testIdx -gt 0) "Found binary smoke test in install.ps1" | ||
| Assert-True (($stageIdx -gt 0) -and ($testIdx -gt 0) -and ($stageIdx -lt $testIdx)) "Binary test runs AFTER bundle is moved out of temp" |
|
|
||
| If the installer (or `apm self-update`) fails at the `Testing binary...` step with `Access is denied` / HRESULT `0x80070005`, an enterprise application control policy ([AppLocker](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/applocker-overview) or [App Control for Business / WDAC](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/)) is blocking execution of `apm.exe` from a user-writable path. | ||
|
|
||
| Starting in this release, the installer stages the binary under `%LOCALAPPDATA%\Programs\apm\releases\<tag>` **before** invoking it, so a single allow-list rule for that path is enough. |
- install.ps1: staging failure now attempts pip fallback (and emits AppLocker/WDAC guidance on access-denied), aligning with the other failure branches in the install block. - install.ps1: reworded the promote-step comment to drop the inaccurate 'atomic' claim; Win32 has no atomic directory replacement, so the comment now describes minimizing the gap and enabling rollback. - scripts/windows/test-install-script.ps1: replaced brittle IndexOf string matching with a PowerShell AST walk; we find Move-Item calls referencing $packageDir+$stagingDir and the $stagedExe --version invocation by structure, not by exact text, so harmless formatting changes won't break CI. - docs/installation.md: dropped 'Starting in this release' time-relative phrasing in favour of a timeless statement. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Default $PinnedVersion bumped to v0.14.0 (current latest); add $OlderVersion = v0.13.0. - Extract Invoke-InstallScript, Get-ShimVersion, New-IsolatedPrefix helpers so multi-step scenarios stay readable. - Test 4a: cross-version upgrade v0.13.0 -> v0.14.0 in the same prefix; asserts shim repoints, --version advances, no .new-*/.old-* leftovers. - Test 4b: same-version reinstall of v0.14.0 to actually exercise the promote/backup branch (releaseDir already exists); asserts apm.exe is the freshly staged copy and backup dirs are cleaned up. - Test 5: real 'apm self-update' end-to-end. Install v0.13.0, run the installed apm.cmd's self-update command. Validates the launch path that issue #1389 originally broke (download install.ps1 from aka.ms/apm-windows + dispatch via PowerShell) and that the final --version no longer reports v0.13.0. Caveat documented in script: self-update fetches install.ps1 from main, not this PR. The new fixes in this PR are validated by Tests 1-4; Test 5 protects the broader launch flow. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The wrapped & powershell.exe call was capturing install.ps1's stdout into the function return, so callers got an Object[] of (stdout lines + int exit code) instead of just the exit code. Piping to Out-Host keeps the output visible in CI logs and lets the function return $LASTEXITCODE cleanly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rs isolated prefix Without APM_INSTALL_DIR, the install.ps1 downloaded by self-update from aka.ms/apm-windows defaults to %LOCALAPPDATA%\Programs\apm\bin instead of our isolated test prefix, so the version assertion against the isolated shim still sees the pre-update v0.13.0 binary. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This was referenced May 19, 2026
danielmeppiel
added a commit
that referenced
this pull request
May 20, 2026
* chore(release): bump to v0.14.1 - Move Unreleased entries into [0.14.1] - 2026-05-20. - Add user-facing entries for #1408 (Windows Defender AV detection) and #1390 (AppLocker/WDAC install survival). - Bump pyproject.toml version 0.14.0 -> 0.14.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(install): restore CWD via monkeypatch.chdir to prevent pollution test_download_callback_includes_chain_in_error used raw os.chdir(tmp_path) without try/finally, leaving CWD pointing at a tmp dir whose apm.yml declared acme/root-pkg. Later in the same pytest worker, test_project_scope_now_supported (added by PR #1405) inherited that polluted CWD and tried to clone real GitHub repos, failing on missing auth in CI. monkeypatch.chdir auto-restores on teardown. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(changelog): collapse to one entry per PR for v0.14.1 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #1389.
TL;DR
The Windows installer (and
apm self-update) failed on enterprise hosts with AppLocker or App Control for Business / WDAC because the binary smoke test invokedapm.exefrom%TEMP%, which is user-writable and therefore denied by default application-control policy (HRESULT0x80070005). The installer then fell back to pip, and pip itself failed because the user's interpreter was Python 3.14 (out of supported range) — leaving the user stuck.This PR stages the release in its final allow-listable location (
%LOCALAPPDATA%\Programs\apm\releases\<tag>.new-<guid>) before running--version, so the smoke test executes from the same path the shim will keep pointing at. It also adds aGet-FileHashfallback for hardened hosts and emits a specific AppLocker/WDAC error message instead of silently fading into pip.Problem (WHY)
User report in #1389 from a corporate Windows estate:
Three symptoms, one architectural root cause and two adjacent papercuts:
CreateProcesson the unsignedapm.exeunder%LOCALAPPDATA%\Temp\apm-install-<guid>\. User-writable paths are not in the default executable allow-list — only%PROGRAMFILES%and%WINDIR%are (AppLocker design guide).Get-FileHash"not recognized" indicates$PSModuleAutoLoadingPreference='None'or a JEA RestrictedRemoteServer host whereMicrosoft.PowerShell.Utilitynever autoloads. The cmdlet has shipped since PS 4.0 and works in Constrained Language Mode if available.Install-ViaPiprequires Python 3.9–3.12; 3.14 is rejected.Approach (WHAT)
$releaseDir.new-<guid>(sibling of the final path) → runapm.exe --versionfrom there → atomic rename to$releaseDir. Existing release is preserved via.old-<guid>until promotion succeeds. A single allow-list rule for%LOCALAPPDATA%\Programs\apm\*now suffices for both install-time and steady-state execution.Get-Sha256Hexhelper triesGet-Command Get-FileHash, thenImport-Module Microsoft.PowerShell.Utility, then falls back to[System.Security.Cryptography.SHA256]over[IO.File]::OpenRead(). Core .NET types are permitted in Constrained Language Mode.Access is denied/0x80070005, surface the AppLocker/WDAC diagnosis with three remediations: allow-list rule,APM_TEMP_DIRoverride, pip fallback.Implementation (HOW)
install.ps1Get-Sha256Hexhelper (lines ~250). Used in the checksum verify step.Test-AccessDeniedError+Write-AppControlGuidancehelpers (lines ~275–305).scripts/windows/test-install-script.ps1(new)Get-Sha256Hex, runs it in a childpowershell.exewith$env:PSModulePath=''andRemove-Module Microsoft.PowerShell.Utility -Force, asserts the hash matchesGet-FileHashbaseline. This proves the fallback works on hardened hosts.Move-Item ... $stagingDirappears before& $stagedExe --versionininstall.ps1.v0.13.0into isolatedAPM_INSTALL_DIR+APM_TEMP_DIRprefixes; asserts the shim exists, points into the per-user releases dir (not temp),apm.cmd --versionreturns the pinned tag, and noapm-install-*leftover is in the temp dir..github/workflows/build-release.yml: new "Test install.ps1 end-to-end (Windows)" step in thebuild-and-testjob'swindows-latestmatrix entry, after the binary build.docs/src/content/docs/getting-started/installation.md: new "Access is denied running apm.exe on Windows" troubleshooting subsection.Trade-offs
APM_TEMP_DIR. The fix is purely a re-ordering plus a defensive fallback.v0.13.0). Network-dependent in CI, but proves true end-to-end behavior. Avoids the complexity of standing up a local HTTPS mirror to spoofapi.github.com. If the runner loses egress, the test fails loudly (correct behavior — install.ps1's job is to download).0x80070005. The test exercises the structural fix (the binary now runs from the allow-listable path) plus the fallback components in isolation. Adding a full AppLocker policy to a shared runner is risky and out of scope.Validation
uv run --extra dev ruff check src/ tests/— silentuv run --extra dev ruff format --check src/ tests/— 787 files already formatteduv run pytest tests/unit/test_update_command.py tests/unit/install/— 743 passedpwshparser check oninstall.ps1+scripts/windows/test-install-script.ps1— both cleanbuild-and-test/windows-latest— will run on this PR viabuild-release.ymlHow to test
On the windows-latest runner (CI), the new step runs automatically. To run locally on a Windows host:
To exercise the AppLocker code path manually: