Skip to content

fix(install): survive AppLocker/WDAC by staging release before binary test (#1389)#1390

Merged
danielmeppiel merged 5 commits into
mainfrom
danielmeppiel/fix-windows-applocker-self-update-1389
May 19, 2026
Merged

fix(install): survive AppLocker/WDAC by staging release before binary test (#1389)#1390
danielmeppiel merged 5 commits into
mainfrom
danielmeppiel/fix-windows-applocker-self-update-1389

Conversation

@danielmeppiel
Copy link
Copy Markdown
Collaborator

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 invoked apm.exe from %TEMP%, which is user-writable and therefore denied by default application-control policy (HRESULT 0x80070005). 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 a Get-FileHash fallback 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:

apm.exe : Access is denied
Get-FileHash : The term 'Get-FileHash' is not recognized
python is 3.14.0 - pip-fallback rejected

Three symptoms, one architectural root cause and two adjacent papercuts:

  1. Fatal: OS-level application control (AppLocker / App Control for Business) denied CreateProcess on the unsigned apm.exe under %LOCALAPPDATA%\Temp\apm-install-<guid>\. User-writable paths are not in the default executable allow-list — only %PROGRAMFILES% and %WINDIR% are (AppLocker design guide).
  2. Adjacent: Get-FileHash "not recognized" indicates $PSModuleAutoLoadingPreference='None' or a JEA RestrictedRemoteServer host where Microsoft.PowerShell.Utility never autoloads. The cmdlet has shipped since PS 4.0 and works in Constrained Language Mode if available.
  3. Adjacent: Install-ViaPip requires Python 3.9–3.12; 3.14 is rejected.

Approach (WHAT)

  • Stage-then-test-then-promote. Extract → move bundle to $releaseDir.new-<guid> (sibling of the final path) → run apm.exe --version from 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.
  • SHA256 fallback. New Get-Sha256Hex helper tries Get-Command Get-FileHash, then Import-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.
  • Actionable error. When the smoke test fails with Access is denied / 0x80070005, surface the AppLocker/WDAC diagnosis with three remediations: allow-list rule, APM_TEMP_DIR override, pip fallback.

Implementation (HOW)

  • install.ps1
    • New Get-Sha256Hex helper (lines ~250). Used in the checksum verify step.
    • New Test-AccessDeniedError + Write-AppControlGuidance helpers (lines ~275–305).
    • Refactored extract/test/install block (lines ~575–680): stage → test → promote with rollback.
  • scripts/windows/test-install-script.ps1 (new)
    • Test 1: extracts Get-Sha256Hex, runs it in a child powershell.exe with $env:PSModulePath='' and Remove-Module Microsoft.PowerShell.Utility -Force, asserts the hash matches Get-FileHash baseline. This proves the fallback works on hardened hosts.
    • Test 2: structural assertion that Move-Item ... $stagingDir appears before & $stagedExe --version in install.ps1.
    • Test 3: end-to-end install of v0.13.0 into isolated APM_INSTALL_DIR + APM_TEMP_DIR prefixes; asserts the shim exists, points into the per-user releases dir (not temp), apm.cmd --version returns the pinned tag, and no apm-install-* leftover is in the temp dir.
  • .github/workflows/build-release.yml: new "Test install.ps1 end-to-end (Windows)" step in the build-and-test job's windows-latest matrix 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

  • No install.ps1 public surface change. No new flags, no new env vars beyond the existing APM_TEMP_DIR. The fix is purely a re-ordering plus a defensive fallback.
  • Test relies on a real published release (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 spoof api.github.com. If the runner loses egress, the test fails loudly (correct behavior — install.ps1's job is to download).
  • AppLocker is not active on the windows-latest runner, so Test 3 cannot directly reproduce HRESULT 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/ — silent
  • uv run --extra dev ruff format --check src/ tests/ — 787 files already formatted
  • uv run pytest tests/unit/test_update_command.py tests/unit/install/ — 743 passed
  • pwsh parser check on install.ps1 + scripts/windows/test-install-script.ps1 — both clean
  • New Windows test wired into build-and-test / windows-latest — will run on this PR via build-release.yml

How to test

On the windows-latest runner (CI), the new step runs automatically. To run locally on a Windows host:

pwsh -NoProfile -ExecutionPolicy Bypass -File scripts/windows/test-install-script.ps1

To exercise the AppLocker code path manually:

$env:APM_TEMP_DIR = "C:\NoExec"  # a path your AppLocker policy denies
irm https://aka.ms/apm-windows | iex
# Expect: clear AppLocker/WDAC guidance with three remediation options.

… 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>
Copilot AI review requested due to automatic review settings May 19, 2026 10:25
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.

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-Sha256Hex with 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 thread install.ps1 Outdated
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).
Comment thread install.ps1 Outdated
try {
Move-Item -Path $packageDir -Destination $stagingDir -Force
} catch {
Write-ErrorText "Failed to stage release at ${stagingDir}: $_"
Comment thread scripts/windows/test-install-script.ps1 Outdated
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.
Daniel Meppiel and others added 4 commits May 19, 2026 14:08
- 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>
@danielmeppiel danielmeppiel merged commit a4957b9 into main May 19, 2026
22 checks passed
@danielmeppiel danielmeppiel deleted the danielmeppiel/fix-windows-applocker-self-update-1389 branch May 19, 2026 13:04
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>
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.

[BUG] APM self-update fails on Windows due to security policies

2 participants