fix(install): detect Git Bash Mandatory ASLR failures (#64651)

This commit is contained in:
Gille 2026-07-16 20:46:46 -06:00 committed by GitHub
parent c856f36459
commit 1f7d2be22f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 419 additions and 7 deletions

View file

@ -705,6 +705,100 @@ function Test-Python {
return $false
}
$script:GitInstallFailureReason = $null
$script:GitBashPath = $null
$script:GitBashProbeOutput = $null
function Test-GitBashCompatibility {
<#
.SYNOPSIS
Verify that Git Bash can launch external MSYS programs, not just evaluate
shell builtins. Mandatory ASLR can allow bash.exe itself to start while
every child linked to msys-2.0.dll fails during fork/spawn.
#>
param([Parameter(Mandatory = $true)][string]$BashPath)
$script:GitBashProbeOutput = $null
if (-not (Test-Path -LiteralPath $BashPath)) {
$script:GitBashProbeOutput = "bash.exe was not found at $BashPath"
return $false
}
$process = New-Object System.Diagnostics.Process
try {
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $BashPath
$startInfo.Arguments = '--noprofile --norc -c "/usr/bin/true; /usr/bin/cat --version >/dev/null"'
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process.StartInfo = $startInfo
if (-not $process.Start()) {
$script:GitBashProbeOutput = "bash.exe did not start"
return $false
}
if (-not $process.WaitForExit(15000)) {
try { $process.Kill() } catch { }
$script:GitBashProbeOutput = "Git Bash compatibility probe timed out"
return $false
}
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
$script:GitBashProbeOutput = ("$stdout`n$stderr").Trim()
return ($process.ExitCode -eq 0)
} catch {
$script:GitBashProbeOutput = $_.Exception.Message
return $false
} finally {
$process.Dispose()
}
}
function Test-MandatoryAslrEnabled {
<# Return true only when Windows reports system-wide ForceRelocateImages=ON. #>
try {
$cmd = Get-Command Get-ProcessMitigation -ErrorAction SilentlyContinue
if (-not $cmd) { return $false }
$mitigations = & $cmd -System
$value = $mitigations.Aslr.ForceRelocateImages
return ($null -ne $value -and $value.ToString().ToUpperInvariant() -eq "ON")
} catch {
return $false
}
}
function Get-GitRootFromBashPath {
param([Parameter(Mandatory = $true)][string]$BashPath)
$binDir = Split-Path -Path $BashPath -Parent
if ((Split-Path -Path $binDir -Leaf) -ine "bin") {
return (Split-Path -Path $binDir -Parent)
}
$parent = Split-Path -Path $binDir -Parent
if ((Split-Path -Path $parent -Leaf) -ieq "usr") {
return (Split-Path -Path $parent -Parent)
}
return $parent
}
function New-GitBashAslrFailureReason {
param([Parameter(Mandatory = $true)][string]$BashPath)
$gitRoot = Get-GitRootFromBashPath -BashPath $BashPath
$escapedRoot = $gitRoot -replace "'", "''"
return @(
"Git Bash at $BashPath cannot launch required MSYS child processes because Windows Mandatory ASLR (ForceRelocateImages) is enabled system-wide. Reinstalling Git will not change this policy."
"Open PowerShell as Administrator and run:"
"`$gitRoot = '$escapedRoot'"
'Get-Item "$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\*.exe" -ErrorAction SilentlyContinue | ForEach-Object { Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }'
"Then rerun Hermes setup. If the override is blocked or later re-applied, ask your Windows administrator to allow this per-program exception."
) -join [Environment]::NewLine
}
function Install-Git {
<#
.SYNOPSIS
@ -737,13 +831,31 @@ function Install-Git {
``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh
shell without a second PATH refresh.
#>
$script:GitInstallFailureReason = $null
Write-Info "Checking Git..."
if (Get-Command git -ErrorAction SilentlyContinue) {
$version = git --version
Write-Success "Git found ($version)"
Set-GitBashEnvVar
return $true
if ($script:GitBashPath -and (Test-GitBashCompatibility -BashPath $script:GitBashPath)) {
Write-Success "Git Bash can launch MSYS programs"
return $true
}
if ($script:GitBashPath -and (Test-MandatoryAslrEnabled)) {
$script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath
Write-Err $script:GitInstallFailureReason
return $false
}
if ($script:GitBashPath) {
$probeDetail = if ($script:GitBashProbeOutput) { ": $script:GitBashProbeOutput" } else { "" }
Write-Warn "System Git Bash could not launch required MSYS programs$probeDetail"
} else {
Write-Warn "Git is on PATH, but its Git Bash installation could not be located."
}
Write-Info "Trying a Hermes-managed PortableGit install instead..."
}
# Download PortableGit into $HermesHome\git. Always works as long as
@ -854,8 +966,25 @@ function Install-Git {
$version = & $gitExe --version
Write-Success "Git $version installed to $gitDir (portable, user-scoped)"
Set-GitBashEnvVar
if (-not $script:GitBashPath) {
throw "PortableGit extraction did not produce a usable bash.exe"
}
if (-not (Test-GitBashCompatibility -BashPath $script:GitBashPath)) {
if (Test-MandatoryAslrEnabled) {
$script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath
} else {
$probeDetail = if ($script:GitBashProbeOutput) { " Probe output: $script:GitBashProbeOutput" } else { "" }
$script:GitInstallFailureReason = "Git Bash at $script:GitBashPath exists but cannot launch required MSYS programs.$probeDetail"
}
throw $script:GitInstallFailureReason
}
Write-Success "Git Bash can launch MSYS programs"
return $true
} catch {
if ($script:GitInstallFailureReason) {
Write-Err $script:GitInstallFailureReason
return $false
}
Write-Err "Could not install portable Git: $_"
Write-Info ""
Write-Info "Fallback: install Git manually from https://git-scm.com/download/win"
@ -872,6 +1001,7 @@ function Set-GitBashEnvVar {
``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before
PATH propagation completes in a newly-spawned shell.
#>
$script:GitBashPath = $null
$candidates = @()
# Our own portable Git install is ALWAYS checked first, so a broken
@ -908,6 +1038,7 @@ function Set-GitBashEnvVar {
if ($candidate -and (Test-Path $candidate)) {
[Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User")
$env:HERMES_GIT_BASH_PATH = $candidate
$script:GitBashPath = $candidate
Write-Info "Set HERMES_GIT_BASH_PATH=$candidate"
return
}
@ -3293,7 +3424,12 @@ $InstallStages += @(
# process), and throws cleanly if uv truly isn't installed yet.
function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } }
function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } }
function Stage-Git { if (-not (Install-Git)) { throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run" } }
function Stage-Git {
if (-not (Install-Git)) {
if ($script:GitInstallFailureReason) { throw $script:GitInstallFailureReason }
throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run"
}
}
# Node is optional (browser tools degrade gracefully without it). Surface
# failure to the JSON contract as skipped=true / reason rather than ok=true,
# so a GUI driver consuming the manifest can distinguish "node ready" from

View file

@ -0,0 +1,120 @@
# Unit tests for install.ps1's Git Bash compatibility and Mandatory-ASLR
# guidance helpers. The installer itself is never executed: functions are
# extracted through the PowerShell AST to avoid downloads, PATH changes, or
# user-environment writes.
$ErrorActionPreference = "Stop"
$repoRoot = Split-Path -Parent (Split-Path -Parent (Split-Path -Parent $MyInvocation.MyCommand.Path))
$installScript = Join-Path $repoRoot "scripts\install.ps1"
$failures = 0
function Assert-Equal {
param($Expected, $Actual, [string]$Label)
if ($Expected -ne $Actual) {
Write-Host "FAIL: $Label" -ForegroundColor Red
Write-Host " expected: $Expected"
Write-Host " actual: $Actual"
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
function Assert-True {
param($Condition, [string]$Label)
if (-not $Condition) {
Write-Host "FAIL: $Label" -ForegroundColor Red
$script:failures++
} else {
Write-Host "OK: $Label" -ForegroundColor Green
}
}
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile(
$installScript, [ref]$tokens, [ref]$parseErrors
)
if ($parseErrors.Count -gt 0) {
throw "install.ps1 has parse errors: $($parseErrors -join '; ')"
}
foreach ($name in @(
"Test-GitBashCompatibility",
"Test-MandatoryAslrEnabled",
"Get-GitRootFromBashPath",
"New-GitBashAslrFailureReason",
"Stage-Git"
)) {
$fnAst = $ast.FindAll(
{
param($node)
$node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and
$node.Name -eq $name
}, $true
) | Select-Object -First 1
if (-not $fnAst) { throw "$name not found in install.ps1" }
. ([scriptblock]::Create($fnAst.Extent.Text))
}
Write-Host ""
Write-Host "-- Git root resolution --"
Assert-Equal "C:\Program Files\Git" `
(Get-GitRootFromBashPath "C:\Program Files\Git\bin\bash.exe") `
"PortableGit/full Git bin layout"
Assert-Equal "C:\Program Files\Git" `
(Get-GitRootFromBashPath "C:\Program Files\Git\usr\bin\bash.exe") `
"usr/bin layout"
Write-Host ""
Write-Host "-- Mandatory ASLR detection --"
function Get-ProcessMitigation {
param([switch]$System)
[pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "ON" } }
}
Assert-Equal $true (Test-MandatoryAslrEnabled) "ForceRelocateImages ON is detected"
function Get-ProcessMitigation {
param([switch]$System)
[pscustomobject]@{ Aslr = [pscustomobject]@{ ForceRelocateImages = "NOTSET" } }
}
Assert-Equal $false (Test-MandatoryAslrEnabled) "ForceRelocateImages NOTSET is not diagnosed"
Write-Host ""
Write-Host "-- Actionable remediation --"
$reason = New-GitBashAslrFailureReason "C:\Program Files\Git\bin\bash.exe"
Assert-True ($reason -match "Mandatory ASLR") "reason identifies Mandatory ASLR"
Assert-True ($reason -match "Reinstalling Git will not change") "reason rejects ineffective reinstall"
Assert-True ($reason -match [regex]::Escape("C:\Program Files\Git")) "reason uses selected Git root"
Assert-True ($reason -match "Set-ProcessMitigation") "reason includes targeted mitigation command"
Write-Host ""
Write-Host "-- External-program probe --"
$gitCommand = Get-Command git -ErrorAction SilentlyContinue
$gitBash = $null
if ($gitCommand -and $gitCommand.Source) {
$gitRoot = Split-Path (Split-Path $gitCommand.Source -Parent) -Parent
foreach ($candidate in @("$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\bash.exe")) {
if (Test-Path -LiteralPath $candidate) { $gitBash = $candidate; break }
}
}
if ($gitBash) {
Assert-Equal $true (Test-GitBashCompatibility $gitBash) `
"installed Git Bash launches external MSYS programs"
} else {
Write-Host "SKIP: no Git Bash found next to git.exe" -ForegroundColor Yellow
}
Write-Host ""
Write-Host "-- Stage failure propagation --"
function Install-Git { return $false }
$script:GitInstallFailureReason = "specific Git Bash failure"
$stageError = $null
try { Stage-Git } catch { $stageError = $_.Exception.Message }
Assert-Equal "specific Git Bash failure" $stageError "Git stage preserves actionable reason"
Write-Host ""
if ($failures -gt 0) {
Write-Host "FAILED: $failures assertion(s) failed" -ForegroundColor Red
exit 1
}
Write-Host "All Git Bash compatibility tests passed." -ForegroundColor Green
exit 0

View file

@ -143,6 +143,62 @@ class TestFindBashSkipsBrokenCustomPath:
assert _find_bash() == str(portable)
class TestGitBashExternalProgramProbe:
"""The Windows health check must exercise MSYS child-process creation."""
def test_probe_runs_external_msys_programs(self, monkeypatch):
import tools.environments.local as local_mod
local_mod._bash_starts_cache.clear()
local_mod._bash_probe_details_cache.clear()
calls = []
def fake_run(argv, **kwargs):
calls.append((argv, kwargs))
return subprocess.CompletedProcess(argv, 0, stdout="", stderr="")
monkeypatch.setattr(local_mod.subprocess, "run", fake_run)
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
assert local_mod._bash_starts(r"C:\Git\bin\bash.exe") is True
assert calls[0][0][-1] == "/usr/bin/true; /usr/bin/cat --version >/dev/null"
def test_aslr_failure_surfaces_targeted_windows_command(
self, tmp_path, monkeypatch
):
import tools.environments.local as local_mod
local_mod._bash_starts_cache.clear()
local_mod._bash_probe_details_cache.clear()
portable = tmp_path / "hermes" / "git" / "bin" / "bash.exe"
portable.parent.mkdir(parents=True)
portable.write_text("", encoding="utf-8")
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
monkeypatch.setenv("HERMES_GIT_BASH_PATH", "")
monkeypatch.setenv("LOCALAPPDATA", str(tmp_path))
monkeypatch.setenv("ProgramFiles", str(tmp_path / "empty-program-files"))
monkeypatch.delenv("ProgramFiles(x86)", raising=False)
monkeypatch.setattr(local_mod.shutil, "which", lambda _name: None)
monkeypatch.setattr(local_mod, "_mandatory_aslr_enabled", lambda: True)
def failed_probe(path: str) -> bool:
local_mod._bash_probe_details_cache[path] = (
"dofork: child -1 - forked process died unexpectedly"
)
return False
monkeypatch.setattr(local_mod, "_bash_starts", failed_probe)
with pytest.raises(RuntimeError) as exc_info:
local_mod._find_bash()
message = str(exc_info.value)
assert "Mandatory ASLR" in message
assert "Reinstalling Git will not change" in message
assert "Set-ProcessMitigation" in message
assert str(tmp_path / "hermes" / "git") in message
@pytest.mark.skipif(
not os.path.isfile("/bin/bash") or sys.platform != "darwin",
reason="reproduces the macOS system-bash-3.2 login-shell swallow",

View file

@ -1,6 +1,7 @@
"""Local execution environment — spawn-per-call with session snapshot."""
import logging
import ntpath
import os
import platform
import re
@ -653,8 +654,19 @@ def _find_bash() -> str:
return candidate
if candidates:
# Last resort: return the first path even if the probe failed, so the
# caller still sees the real bash error instead of "not found".
probe_details = "\n".join(
detail
for candidate in candidates
if (detail := _bash_probe_details_cache.get(candidate))
)
if _mandatory_aslr_enabled() is True or _looks_like_msys_spawn_failure(
probe_details
):
raise RuntimeError(_git_bash_aslr_help(candidates[0], probe_details))
# Last resort for failures unrelated to the known MSYS/ASLR class:
# return the first path so the caller still sees the real bash error
# instead of the less useful "not found" message.
return candidates[0]
raise RuntimeError(
@ -665,14 +677,100 @@ def _find_bash() -> str:
_bash_starts_cache: dict[str, bool] = {}
_bash_probe_details_cache: dict[str, str] = {}
_mandatory_aslr_enabled_cache: "bool | None" = None
_BASH_EXTERNAL_PROGRAM_PROBE = "/usr/bin/true; /usr/bin/cat --version >/dev/null"
def _looks_like_msys_spawn_failure(details: str) -> bool:
"""Match Git-for-Windows child-launch failures associated with ASLR."""
lowered = details.lower()
return any(
marker in lowered
for marker in (
"dofork:",
"child_copy:",
"0xc0000142",
"0xc0000005",
)
)
def _mandatory_aslr_enabled() -> "bool | None":
"""Return Windows' system-wide ForceRelocateImages state when available."""
global _mandatory_aslr_enabled_cache
if _mandatory_aslr_enabled_cache is not None:
return _mandatory_aslr_enabled_cache
try:
powershell = shutil.which("powershell.exe") or "powershell.exe"
result = subprocess.run(
[
powershell,
"-NoProfile",
"-NonInteractive",
"-Command",
"(Get-ProcessMitigation -System).Aslr.ForceRelocateImages.ToString()",
],
capture_output=True,
text=True,
timeout=10,
creationflags=windows_hide_flags(),
)
if result.returncode != 0:
return None
value = (result.stdout or "").strip().upper()
if value == "ON":
_mandatory_aslr_enabled_cache = True
return True
if value in {"OFF", "NOTSET"}:
_mandatory_aslr_enabled_cache = False
return False
except Exception as exc:
logger.debug("Could not query Windows Mandatory ASLR state: %s", exc)
return None
def _git_root_from_bash(bash: str) -> str:
"""Resolve Git's root from either <root>/bin or <root>/usr/bin bash."""
bin_dir = ntpath.dirname(ntpath.normpath(bash))
if ntpath.basename(bin_dir).lower() != "bin":
return ntpath.dirname(bin_dir)
parent = ntpath.dirname(bin_dir)
if ntpath.basename(parent).lower() == "usr":
return ntpath.dirname(parent)
return parent
def _git_bash_aslr_help(bash: str, details: str = "") -> str:
"""Build the targeted per-program Mandatory-ASLR remediation."""
git_root = _git_root_from_bash(bash)
escaped_root = git_root.replace("'", "''")
detail_line = f"\nGit Bash probe output: {details[:500]}" if details else ""
return (
f"Git Bash at {bash} cannot launch required MSYS child processes while "
"Windows Mandatory ASLR (ForceRelocateImages) is enabled, or its output "
f"matches that Git-for-Windows failure class.{detail_line}\n"
"Reinstalling Git will not change the Windows mitigation policy. Open "
"PowerShell as Administrator and run:\n"
f"$gitRoot = '{escaped_root}'\n"
'Get-Item "$gitRoot\\bin\\bash.exe", "$gitRoot\\usr\\bin\\*.exe" '
"-ErrorAction SilentlyContinue | ForEach-Object { "
"Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }\n"
"Then restart Hermes. If the override is blocked or later re-applied, "
"ask your Windows administrator to allow this per-program exception."
)
def _bash_starts(bash: str) -> bool:
"""True if *bash* can run a trivial non-login command.
"""True if *bash* can launch external MSYS programs.
Uses ``--noprofile --norc`` so a broken login post-install
(``Directory \\drivers\\etc``) does not falsely condemn an otherwise
usable bash. Cached per path for the process lifetime.
usable bash. The external ``true`` and ``cat`` calls are intentional:
a builtin-only ``exit 0`` probe misses Git-for-Windows fork/spawn failures
under system-wide Mandatory ASLR. Cached per path for the process lifetime.
"""
cached = _bash_starts_cache.get(bash)
if cached is not None:
@ -680,7 +778,7 @@ def _bash_starts(bash: str) -> bool:
try:
result = subprocess.run(
[bash, "--noprofile", "--norc", "-c", "exit 0"],
[bash, "--noprofile", "--norc", "-c", _BASH_EXTERNAL_PROGRAM_PROBE],
capture_output=True,
text=True,
timeout=15,
@ -689,8 +787,10 @@ def _bash_starts(bash: str) -> bool:
ok = result.returncode == 0
if not ok:
combined = f"{result.stdout or ''}{result.stderr or ''}"
_bash_probe_details_cache[bash] = combined.strip()[:2000]
logger.debug("bash probe failed for %s: %s", bash, combined.strip()[:200])
except Exception as exc:
_bash_probe_details_cache[bash] = str(exc)[:2000]
logger.debug("bash probe error for %s: %s", bash, exc)
ok = False