diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1b554bce2f..7355102a0d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -35,6 +35,7 @@ jobs:
src/ tests/
scripts/lint_architecture_boundaries.py
scripts/architecture_linter/
+ scripts/windows_native_symlink_probe_entry.py
- name: Ruff format check
run: >-
@@ -42,6 +43,7 @@ jobs:
src/ tests/
scripts/lint_architecture_boundaries.py
scripts/architecture_linter/
+ scripts/windows_native_symlink_probe_entry.py
- name: Check YAML encoding safety
run: |
@@ -92,7 +94,8 @@ jobs:
--fail-on=R0801 \
src/apm_cli/ \
scripts/lint_architecture_boundaries.py \
- scripts/architecture_linter/
+ scripts/architecture_linter/ \
+ scripts/windows_native_symlink_probe_entry.py
- name: Lint - auth-protocol boundary (#1212 anti-regression)
run: bash scripts/lint-auth-signals.sh
@@ -167,6 +170,58 @@ jobs:
git config --list --show-origin
echo "::endgroup::"
+ windows-native-standard-user-symlink-gate:
+ name: Windows Native Symlink Acceptance
+ runs-on: windows-latest
+ timeout-minutes: 15
+ permissions:
+ contents: read
+
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ env:
+ GIT_CONFIG_COUNT: '1'
+ GIT_CONFIG_KEY_0: core.autocrlf
+ GIT_CONFIG_VALUE_0: 'false'
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+
+ - name: Set up Python
+ uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
+ with:
+ python-version: ${{ env.PYTHON_VERSION }}
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
+
+ - name: Install dependencies
+ run: uv sync --frozen --extra dev
+
+ - name: Prove native standard-user fallback
+ shell: pwsh
+ timeout-minutes: 10
+ env:
+ APM_NATIVE_EXPECTED_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
+ run: >-
+ ./scripts/windows_native_standard_user_symlink_gate.ps1
+ -ExpectedHead $env:APM_NATIVE_EXPECTED_HEAD
+
+ - name: Restore owned native fixture
+ if: always()
+ shell: pwsh
+ timeout-minutes: 2
+ run: ./scripts/windows_native_standard_user_symlink_gate.ps1 -CleanupOnly
+
+ - name: Retain native proof and restoration evidence
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: windows-native-symlink-${{ github.run_attempt }}
+ path: ${{ runner.temp }}/apm-native-symlink-evidence/
+ if-no-files-found: error
+ retention-days: 30
+
windows-git-discovery:
name: Windows Git Discovery (Python ${{ matrix.python-version }})
runs-on: windows-latest
diff --git a/docs/src/content/docs/consumer/install-packages.md b/docs/src/content/docs/consumer/install-packages.md
index 413f1ff63c..23b045ddfb 100644
--- a/docs/src/content/docs/consumer/install-packages.md
+++ b/docs/src/content/docs/consumer/install-packages.md
@@ -206,6 +206,18 @@ For the full flag reference, run `apm install --help` or see
## When things go wrong
+- **Symlink checkout fallback.** If a checked-out file contains only a short
+ relative path (for example `../shared/config.yml`) instead of real content,
+ Git substituted a plain-text fallback for a symlink it could not create.
+ APM preserves Git's detected `core.symlinks` setting instead of letting an
+ inherited global value override it; this capability check is not
+ Windows-specific, but Windows non-admin accounts are the common case
+ without symlink-creation rights. Explicit command-scope Git settings still
+ take precedence; APM does not change your global Git configuration. A
+ successful checkout therefore does not guarantee that a package requiring
+ real symlinks works -- see "Skipped symlinked agent source" below for the
+ related install-time check on agent sources. Use a package that ships real
+ source files, or a symlink-capable environment.
- **Critical security finding.** Install aborts with the offending
characters and file path. Patch upstream when you can; use
`--force` only when you can document the exception.
diff --git a/docs/src/content/docs/contributing/integration-testing.md b/docs/src/content/docs/contributing/integration-testing.md
index 65869c659f..6e0a86614e 100644
--- a/docs/src/content/docs/contributing/integration-testing.md
+++ b/docs/src/content/docs/contributing/integration-testing.md
@@ -118,6 +118,7 @@ what the test family you want actually requires.
| `requires_e2e_mode` | Opt-in for the heavyweight golden-scenario suite | `export APM_E2E_TESTS=1` |
| `requires_network_integration` | Opt-in for tests that hit live registries | `export APM_RUN_INTEGRATION_TESTS=1` |
| `requires_windows` | A Windows-only process or filesystem boundary | Run on Windows |
+| `requires_windows_native_standard_user` | Verified standard-user token without symlink privilege | The disposable Windows native-symlink CI job; do not enable manually |
| `requires_inference` | Opt-in for tests that call inference APIs | `export APM_RUN_INFERENCE_TESTS=1` |
| `requires_github_token` | A token usable against `github.com` / GitHub Models | `export GITHUB_APM_PAT=...` (or `GITHUB_TOKEN`) |
| `requires_ado_pat` | Azure DevOps PAT for ADO host tests | `export ADO_APM_PAT=...` |
@@ -456,6 +457,20 @@ fallback; a candidate-count regression checks linear extension scanning.
Only a native Windows run demonstrates Windows behavior; passing mocks or
collection alone do not.
+The **Windows Native Symlink Acceptance** job checks out the exact PR head and
+uses a temporary standard account on a disposable hosted runner. It requires
+successful ordinary file I/O, no administrator or symlink privilege, and actual
+Windows error 1314 before running the Git/APM fallback regression. The test keeps
+inherited global `core.symlinks=true` while honoring Git's native local `false`;
+explicit command-scope intent remains covered separately.
+
+The job requires a passing native case, failure after removing the local
+precedence guard in memory, and a restored pass. Empty or skipped runs fail.
+Account, profile, scratch and any changed Developer Mode value are cleaned up
+independently, with an `always()` cleanup fallback. A restoration failure fails
+the job. This is link-text checkout evidence, not archive-symlink support, and
+the job does not change repository protection or replace required SDL scanning.
+
Plugin sequential-install coverage checks deployed-file removal and lockfile
ownership after uninstall, plus unchanged files and ownership for the retained
skill. The local fixture covers both an independent skill and a separately
diff --git a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
index b7beb4a380..aef988cfb7 100644
--- a/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
+++ b/packages/apm-guide/.apm/skills/apm-usage/dependencies.md
@@ -71,6 +71,15 @@ fallback requires an exhausted plan with an executed same-origin effective
HTTPS attempt. Path containment rejects symlink or traversal escapes.
See [GitLab authentication and fetch policy](authentication.md#gitlab-saas-or-self-managed).
+### Git symlink checkout fallback
+
+APM preserves Git's detected `core.symlinks` setting and explicit command-scope
+overrides without changing global Git configuration, so a successful checkout
+is not proof that a package requiring real symlinks works (common on non-admin
+Windows accounts). See [Install packages: symlink checkout
+fallback](https://microsoft.github.io/apm/consumer/install-packages/#when-things-go-wrong)
+for the full behavior and troubleshooting steps.
+
### Custom git ports
Non-default git ports are preserved on `https://`, `http://`, and `ssh://` URLs
diff --git a/pyproject.toml b/pyproject.toml
index 5a1070eae1..4bedac95ac 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -212,6 +212,7 @@ markers = [
"requires_ado_bearer: requires az CLI logged in + APM_TEST_ADO_BEARER=1",
"requires_network_integration: requires APM_RUN_INTEGRATION_TESTS=1",
"requires_windows: requires a Windows host",
+ "requires_windows_native_standard_user: requires the disposable Windows runner's verified non-admin token and symlink denial",
"requires_apm_binary: requires built apm binary on PATH (or APM_BINARY_PATH)",
"requires_runtime_codex: requires codex runtime installed",
"requires_runtime_copilot: requires GitHub Copilot CLI runtime installed",
diff --git a/scripts/windows_native_standard_user_symlink_gate.ps1 b/scripts/windows_native_standard_user_symlink_gate.ps1
new file mode 100644
index 0000000000..9d69ca4280
--- /dev/null
+++ b/scripts/windows_native_standard_user_symlink_gate.ps1
@@ -0,0 +1,290 @@
+param(
+ [string]$ExpectedHead,
+ [switch]$CleanupOnly
+)
+
+$ErrorActionPreference = 'Stop'
+$RegistryPath = 'SOFTWARE\Microsoft\Windows\CurrentVersion\AppModelUnlock'
+$RegistryValue = 'AllowDevelopmentWithoutDevLicense'
+
+if ($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:RUNNER_OS -ne 'Windows') {
+ throw 'This gate may only mutate its disposable GitHub-hosted Windows runner.'
+}
+$RunnerTemp = (Resolve-Path -LiteralPath $env:RUNNER_TEMP).Path
+$Control = Join-Path $RunnerTemp 'apm-native-symlink-evidence'
+$StateFile = Join-Path $Control 'state.json'
+$Root = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot '..')).Path
+$RunnerSid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User
+
+function Write-State($State) {
+ $temporary = Join-Path $Control 'state.pending.json'
+ [System.IO.File]::WriteAllText($temporary, ($State | ConvertTo-Json -Depth 8))
+ Move-Item -LiteralPath $temporary -Destination $StateFile -Force
+}
+
+function Get-DeveloperModeState {
+ $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($RegistryPath)
+ if ($null -eq $key) {
+ return [ordered]@{ KeyExists = $false; ValueExists = $false; Kind = $null; Value = $null }
+ }
+ try {
+ $exists = $key.GetValueNames() -contains $RegistryValue
+ return [ordered]@{
+ KeyExists = $true
+ ValueExists = $exists
+ Kind = $(if ($exists) { $key.GetValueKind($RegistryValue).ToString() } else { $null })
+ Value = $(if ($exists) { $key.GetValue($RegistryValue) } else { $null })
+ }
+ }
+ finally { $key.Close() }
+}
+
+function Assert-OwnedState($State) {
+ if ($State.RunId -ne $env:GITHUB_RUN_ID -or $State.RunAttempt -ne $env:GITHUB_RUN_ATTEMPT -or $State.RunnerSid -ne $RunnerSid.Value) {
+ throw 'Native cleanup state does not belong to this runner invocation.'
+ }
+ if ($State.UserName -notmatch '^apmns[a-f0-9]{12}$') {
+ throw 'Invalid owned account name in cleanup state.'
+ }
+ if ([System.IO.Path]::GetDirectoryName($State.Scratch) -ne $RunnerTemp -or
+ [System.IO.Path]::GetFileName($State.Scratch) -notmatch '^apm-native-[a-f0-9]{32}$') {
+ throw 'Refusing cleanup outside the exact owned scratch directory.'
+ }
+}
+
+function Stop-OwnedProcess($State) {
+ if (-not $State.ChildPid) { return }
+ $process = Get-Process | Where-Object { $_.Id -eq $State.ChildPid }
+ if ($null -eq $process) { return }
+ if ($process.StartTime.ToUniversalTime().Ticks.ToString() -ne $State.ChildStartTicks) {
+ throw 'The recorded PID was reused; refusing to terminate it.'
+ }
+ $process.Kill($true)
+ if (-not $process.WaitForExit(30000)) { throw 'The owned child process did not terminate.' }
+}
+
+function Restore-DeveloperMode($State) {
+ $before = $State.RegistryBefore | ConvertTo-Json -Compress
+ $current = Get-DeveloperModeState
+ if (($current | ConvertTo-Json -Compress) -eq $before) { return }
+ if (-not $State.RegistryChanged -or -not $current.ValueExists -or $current.Kind -ne 'DWord' -or $current.Value -ne 0) {
+ throw 'Developer Mode changed unexpectedly; refusing to overwrite unrelated state.'
+ }
+ $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($RegistryPath, $true)
+ try {
+ $key.SetValue($RegistryValue, [int]$State.RegistryBefore.Value, [Microsoft.Win32.RegistryValueKind]::DWord)
+ }
+ finally { $key.Close() }
+ if (((Get-DeveloperModeState) | ConvertTo-Json -Compress) -ne $before) {
+ throw 'Developer Mode key/value presence, type or value was not restored.'
+ }
+}
+
+function Remove-OwnedProfile($State) {
+ if (-not $State.UserSid) { return }
+ $profiles = @(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($State.UserSid)'")
+ if ($profiles.Count -gt 1) { throw 'More than one profile matches the newly created SID.' }
+ foreach ($profile in $profiles) {
+ if ($profile.Loaded) { throw 'The owned user profile is still loaded.' }
+ $profile | Remove-CimInstance
+ }
+ if (@(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($State.UserSid)'").Count) {
+ throw 'The owned user profile was not removed.'
+ }
+}
+
+function Remove-OwnedUser($State) {
+ $users = @(Get-LocalUser | Where-Object { $_.Name -eq $State.UserName })
+ if (-not $users.Count) { return }
+ if ($users.Count -ne 1 -or -not $State.UserSid -or $users[0].SID.Value -ne $State.UserSid) {
+ throw 'The account identity differs from the recorded created SID; refusing deletion.'
+ }
+ Remove-LocalUser -SID $users[0].SID
+ if (@(Get-LocalUser | Where-Object { $_.Name -eq $State.UserName }).Count) {
+ throw 'The owned local account was not removed.'
+ }
+}
+
+function Save-ChildEvidence($State) {
+ if (-not (Test-Path -LiteralPath $State.Scratch)) { return }
+ foreach ($name in @('stdout.log', 'stderr.log', 'native-proof.json', 'baseline.xml', 'mutation.xml', 'restored.xml')) {
+ $source = Join-Path $State.Scratch $name
+ if (Test-Path -LiteralPath $source -PathType Leaf) {
+ Copy-Item -LiteralPath $source -Destination (Join-Path $Control $name) -Force
+ }
+ }
+}
+
+function Invoke-OwnedCleanup($State) {
+ Assert-OwnedState $State
+ $failures = [System.Collections.Generic.List[string]]::new()
+ $actions = [ordered]@{
+ process = { Stop-OwnedProcess $State }
+ evidence = { Save-ChildEvidence $State }
+ registry = { Restore-DeveloperMode $State }
+ profile = { Remove-OwnedProfile $State }
+ account = { Remove-OwnedUser $State }
+ scratch = {
+ if (Test-Path -LiteralPath $State.Scratch) {
+ Remove-Item -LiteralPath $State.Scratch -Recurse -Force
+ }
+ if (Test-Path -LiteralPath $State.Scratch) { throw 'Owned scratch/ACLs remain.' }
+ }
+ }
+ foreach ($entry in $actions.GetEnumerator()) {
+ try { & $entry.Value }
+ catch { $failures.Add("$($entry.Key): $($_.Exception.Message)") }
+ }
+ $State.CleanupComplete = $failures.Count -eq 0
+ $State.CleanupFailures = @($failures)
+ $State.CleanupHistory = @($State.CleanupHistory) + @([ordered]@{
+ At = [DateTime]::UtcNow.ToString('o')
+ Complete = $State.CleanupComplete
+ Failures = @($failures)
+ })
+ Write-State $State
+ if ($failures.Count) {
+ throw "Native cleanup failed: $($failures -join '; ')"
+ }
+ Write-Host '[+] Native account, profile, registry and subject scratch restored.'
+}
+
+if ($CleanupOnly) {
+ if (-not (Test-Path -LiteralPath $StateFile)) {
+ Write-Host '[i] No persisted native setup state; no subject resources were created.'
+ exit 0
+ }
+ $state = Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json -AsHashtable
+ Invoke-OwnedCleanup $state
+ exit 0
+}
+
+if ($ExpectedHead -notmatch '^[0-9a-f]{40}$') { throw 'An exact PR/source SHA is required.' }
+if (Test-Path -LiteralPath $Control) { throw 'Native evidence directory already exists; refusing reuse.' }
+$principal = [System.Security.Principal.WindowsPrincipal]::new([System.Security.Principal.WindowsIdentity]::GetCurrent())
+if (-not $principal.IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)) {
+ throw 'Privileged fixture setup requires the hosted runner administrator, never the subject user.'
+}
+$registryBefore = Get-DeveloperModeState
+if ($registryBefore.ValueExists -and $registryBefore.Kind -ne 'DWord') {
+ throw 'Unexpected Developer Mode registry type; no machine changes were made.'
+}
+$nonce = [Guid]::NewGuid().ToString('N')
+$userName = 'apmns' + $nonce.Substring(0, 12)
+if (@(Get-LocalUser | Where-Object { $_.Name -eq $userName }).Count) { throw 'Account collision.' }
+$scratch = Join-Path $RunnerTemp ('apm-native-' + $nonce)
+if (Test-Path -LiteralPath $scratch) { throw 'Scratch collision.' }
+
+New-Item -ItemType Directory -Path $Control | Out-Null
+$controlAcl = [System.Security.AccessControl.DirectorySecurity]::new()
+$controlAcl.SetAccessRuleProtection($true, $false)
+$controlAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
+ $RunnerSid, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'
+))
+Set-Acl -LiteralPath $Control -AclObject $controlAcl
+$state = [ordered]@{
+ RunId = $env:GITHUB_RUN_ID
+ RunAttempt = $env:GITHUB_RUN_ATTEMPT
+ RunnerSid = $RunnerSid.Value
+ Head = $ExpectedHead
+ UserName = $userName
+ UserSid = $null
+ Scratch = $scratch
+ RegistryBefore = $registryBefore
+ RegistryChanged = $false
+ ChildPid = $null
+ ChildStartTicks = $null
+ ChildExit = $null
+ Failure = $null
+ CleanupComplete = $false
+ CleanupFailures = @()
+ CleanupHistory = @()
+}
+Write-State $state
+$failure = $null
+$cleanupFailure = $null
+try {
+ $bytes = [System.Security.Cryptography.RandomNumberGenerator]::GetBytes(24)
+ $password = ConvertTo-SecureString ([Convert]::ToBase64String($bytes) + 'aA1!') -AsPlainText -Force
+ $user = New-LocalUser -Name $userName -Password $password -AccountNeverExpires -PasswordNeverExpires
+ $state.UserSid = $user.SID.Value
+ Write-State $state
+ $usersGroup = Get-LocalGroup -SID 'S-1-5-32-545'
+ if (-not @(Get-LocalGroupMember -Group $usersGroup | Where-Object { $_.SID -eq $user.SID }).Count) {
+ Add-LocalGroupMember -Group $usersGroup -Member $user
+ }
+ if (@(Get-LocalGroupMember -SID 'S-1-5-32-544' | Where-Object { $_.SID -eq $user.SID }).Count) {
+ throw 'Created subject unexpectedly belongs to Administrators.'
+ }
+ if (@(Get-CimInstance -ClassName Win32_UserProfile -Filter "SID='$($user.SID.Value)'").Count) {
+ throw 'A profile existed before the created subject was started.'
+ }
+ if ($registryBefore.ValueExists -and $registryBefore.Value -eq 1) {
+ $state.RegistryChanged = $true
+ Write-State $state
+ $key = [Microsoft.Win32.Registry]::LocalMachine.OpenSubKey($RegistryPath, $true)
+ try { $key.SetValue($RegistryValue, 0, [Microsoft.Win32.RegistryValueKind]::DWord) }
+ finally { $key.Close() }
+ }
+ New-Item -ItemType Directory -Path $scratch | Out-Null
+ $scratchAcl = Get-Acl -LiteralPath $scratch
+ $scratchAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new(
+ $user.SID, 'FullControl', 'ContainerInherit, ObjectInherit', 'None', 'Allow'
+ ))
+ Set-Acl -LiteralPath $scratch -AclObject $scratchAcl
+ foreach ($name in @('home', 'temp', 'cache', 'appdata', 'localappdata')) {
+ New-Item -ItemType Directory -Path (Join-Path $scratch $name) | Out-Null
+ }
+ $env:HOME = Join-Path $scratch 'home'
+ $env:USERPROFILE = $env:HOME
+ $env:HOMEDRIVE = [System.IO.Path]::GetPathRoot($env:HOME).TrimEnd('\')
+ $env:HOMEPATH = $env:HOME.Substring($env:HOMEDRIVE.Length)
+ $env:TEMP = Join-Path $scratch 'temp'
+ $env:TMP = $env:TEMP
+ $env:UV_CACHE_DIR = Join-Path $scratch 'cache'
+ $env:APPDATA = Join-Path $scratch 'appdata'
+ $env:LOCALAPPDATA = Join-Path $scratch 'localappdata'
+ $env:PYTHONDONTWRITEBYTECODE = '1'
+ $env:PYTEST_ADDOPTS = ''
+ $env:APM_WINDOWS_NATIVE_STANDARD_USER = $null
+ $env:APM_WINDOWS_NATIVE_MUTATION = $null
+ $python = Join-Path $Root '.venv\Scripts\python.exe'
+ $entry = Join-Path $Root 'scripts\windows_native_symlink_probe_entry.py'
+ $credential = [System.Management.Automation.PSCredential]::new("$env:COMPUTERNAME\$userName", $password)
+ $arguments = @(
+ '-B', "`"$entry`"", '--root', "`"$Root`"", '--scratch', "`"$scratch`"",
+ '--expected-sid', $user.SID.Value, '--expected-head', $ExpectedHead
+ )
+ $process = Start-Process -FilePath $python -ArgumentList $arguments -Credential $credential `
+ -WorkingDirectory $scratch -PassThru `
+ -RedirectStandardOutput (Join-Path $scratch 'stdout.log') `
+ -RedirectStandardError (Join-Path $scratch 'stderr.log')
+ $process.Handle | Out-Null
+ $state.ChildPid = $process.Id
+ $state.ChildStartTicks = $process.StartTime.ToUniversalTime().Ticks.ToString()
+ Write-State $state
+ if (-not $process.WaitForExit(480000)) { throw 'Native subject exceeded its eight-minute timeout.' }
+ $process.Refresh()
+ $state.ChildExit = $process.ExitCode
+ Write-State $state
+ if ($process.ExitCode -ne 0) { throw "Native subject failed with exit $($process.ExitCode)." }
+ if (-not (Test-Path -LiteralPath (Join-Path $scratch 'native-proof.json') -PathType Leaf)) {
+ throw 'The native subject produced no positive proof.'
+ }
+}
+catch {
+ $failure = $_.Exception.Message
+ $state.Failure = $failure
+}
+finally {
+ try { Invoke-OwnedCleanup $state }
+ catch { $cleanupFailure = $_.Exception.Message }
+ foreach ($name in @('stdout.log', 'stderr.log')) {
+ $log = Join-Path $Control $name
+ if (Test-Path -LiteralPath $log) { Get-Content -LiteralPath $log }
+ }
+}
+if ($failure) { Write-Host "[x] $failure" }
+if ($cleanupFailure) { Write-Host "[x] $cleanupFailure" }
+if ($failure -or $cleanupFailure) { exit 1 }
+Write-Host '[+] Native symlink acceptance and independent restoration completed.'
diff --git a/scripts/windows_native_symlink_probe_entry.py b/scripts/windows_native_symlink_probe_entry.py
new file mode 100644
index 0000000000..b68e361b0c
--- /dev/null
+++ b/scripts/windows_native_symlink_probe_entry.py
@@ -0,0 +1,249 @@
+"""Run the native symlink regression under the disposable runner's standard user."""
+
+from __future__ import annotations
+
+import argparse
+import csv
+import ctypes
+import io
+import json
+import os
+import subprocess
+import sys
+import tempfile
+import xml.etree.ElementTree as ET
+from pathlib import Path
+
+from apm_cli.utils.console import _rich_error, _rich_info
+from apm_cli.utils.git_env import get_git_executable
+
+NATIVE_NODE = (
+ "tests/integration/test_windows_native_symlink.py::test_native_standard_user_symlink_fallback"
+)
+MUTATION_FAILURE = "native-symlink-precedence: APM clone failed after native Git fallback succeeded"
+
+
+def _whoami_rows(option: str) -> list[list[str]]:
+ """Read token information without printing the process environment."""
+ executable = Path(os.environ["SYSTEMROOT"]) / "System32" / "whoami.exe"
+ result = subprocess.run( # noqa: S603 - fixed OS utility, no shell
+ [str(executable), option, "/fo", "csv", "/nh"],
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ )
+ return list(csv.reader(io.StringIO(result.stdout)))
+
+
+def probe_native_context(scratch: Path, expected_sid: str) -> dict[str, object]:
+ """Require an actual writable, non-admin token without symlink privilege."""
+ if os.name != "nt":
+ raise RuntimeError("This acceptance probe requires native Windows")
+ users = _whoami_rows("/user")
+ if len(users) != 1 or len(users[0]) != 2 or users[0][1] != expected_sid:
+ raise RuntimeError("The child token does not match the created standard-user SID")
+ groups = _whoami_rows("/groups")
+ privileges = _whoami_rows("/priv")
+ if not groups or not privileges or any(len(row) < 3 for row in (*groups, *privileges)):
+ raise RuntimeError("Windows token information is incomplete")
+ group_sids = [row[2] for row in groups]
+ privilege_names = [row[0] for row in privileges]
+ if ctypes.windll.shell32.IsUserAnAdmin() or "S-1-5-32-544" in group_sids:
+ raise RuntimeError("The acceptance process must not have an administrator token")
+ if "SeCreateSymbolicLinkPrivilege" in privilege_names:
+ raise RuntimeError("The acceptance token must lack symlink privilege, even if disabled")
+
+ scratch.mkdir(parents=True, exist_ok=False)
+ target = scratch / "ordinary.txt"
+ target.write_bytes(b"ordinary read/write succeeds")
+ if target.read_bytes() != b"ordinary read/write succeeds":
+ raise RuntimeError("Ordinary scratch I/O failed")
+ link = scratch / "link.txt"
+ try:
+ os.symlink(target, link)
+ except OSError as error:
+ if error.winerror != 1314:
+ raise RuntimeError(
+ f"Expected privilege denial 1314, received {error.winerror}"
+ ) from error
+ else:
+ link.unlink()
+ raise RuntimeError(
+ "Symlink creation succeeded; this is not the required denied-capability case"
+ )
+ return {
+ "sid": expected_sid,
+ "username": users[0][0],
+ "group_sids": group_sids,
+ "privileges": privilege_names,
+ "administrator": False,
+ "ordinary_io": True,
+ "symlink_winerror": 1314,
+ }
+
+
+def check_junit(report: Path, *, mutation: bool) -> int:
+ """Reject skipped/empty/error results and require the real regression node."""
+ # Only read the report from our own bounded pytest subprocess.
+ cases = ET.parse(report).getroot().findall(".//testcase") # noqa: S314
+ native = [
+ case
+ for case in cases
+ if case.get("name") == NATIVE_NODE.rsplit("::", 1)[1]
+ and case.get("classname") == "tests.integration.test_windows_native_symlink"
+ ]
+ if len(native) != 1 or any(
+ case.find(tag) is not None for case in cases for tag in ("skipped", "error")
+ ):
+ raise ValueError("The native regression must execute exactly once without skips or errors")
+ failures = [case.find("failure") for case in cases if case.find("failure") is not None]
+ if mutation:
+ if len(cases) != 1 or len(failures) != 1:
+ raise ValueError("The mutation must fail exactly the native regression")
+ if MUTATION_FAILURE not in (failures[0].text or ""):
+ raise ValueError("The mutation failed for an unrelated reason")
+ elif failures:
+ raise ValueError("The restored native regression and preservation controls must pass")
+ return len(cases)
+
+
+def source_provenance(root: Path, expected_head: str) -> dict[str, str]:
+ """Bind imports and Git state to the explicitly checked-out PR source."""
+ import apm_cli
+
+ source = Path(apm_cli.__file__).resolve().parent
+ if source != root / "src" / "apm_cli":
+ raise RuntimeError(f"APM imported from a different checkout: {source}")
+ python = Path(sys.executable)
+ if python.parent != root / ".venv" / "Scripts":
+ raise RuntimeError(
+ "The acceptance process must use this checkout's provisioned interpreter"
+ )
+ binary = python.with_name("apm.exe")
+ if not binary.is_file():
+ raise RuntimeError("The provisioned APM executable is missing")
+ command = [get_git_executable(), "-c", f"safe.directory={root}", "-C", str(root)]
+ head = subprocess.check_output( # noqa: S603 - resolved Git, fixed argv, no shell
+ [*command, "rev-parse", "HEAD"], text=True, timeout=30
+ ).strip()
+ status = subprocess.check_output( # noqa: S603 - resolved Git, fixed argv, no shell
+ [*command, "status", "--porcelain", "--untracked-files=no"], text=True, timeout=30
+ )
+ if head != expected_head:
+ raise RuntimeError(f"Expected source head {expected_head}, found {head}")
+ if status:
+ paths = status.splitlines()
+ raise RuntimeError(
+ f"The tracked acceptance source is dirty ({len(paths)} paths):\n"
+ + "\n".join(paths[:20])
+ )
+ os.environ["APM_BINARY_PATH"] = str(binary)
+ return {"head": head, "python": str(python), "apm_binary": str(binary), "source": str(source)}
+
+
+def run_phase(root: Path, scratch: Path, name: str) -> dict[str, object]:
+ """Capture a real pytest exit and inspect its machine-readable result."""
+ mutation = name == "mutation"
+ env = dict(os.environ)
+ if mutation:
+ env["APM_WINDOWS_NATIVE_MUTATION"] = "drop-local-precedence"
+ else:
+ env.pop("APM_WINDOWS_NATIVE_MUTATION", None)
+ report = scratch / f"{name}.xml"
+ nodes = [NATIVE_NODE]
+ if name == "baseline":
+ nodes.insert(0, "tests/unit/cache/test_git_symlink_config.py")
+ command = [
+ sys.executable,
+ "-B",
+ "-m",
+ "pytest",
+ "-p",
+ "no:cacheprovider",
+ "-o",
+ "addopts=",
+ "-o",
+ "junit_family=xunit1",
+ "--strict-markers",
+ "--basetemp",
+ str(scratch / f"{name}-cases"),
+ "-vv",
+ "--tb=short",
+ "--show-capture=no",
+ "--no-showlocals",
+ f"--junitxml={report}",
+ *nodes,
+ ]
+ result = subprocess.run( # noqa: S603 - pinned interpreter and fixed test nodes, no shell
+ command, cwd=root, env=env, check=False, timeout=180
+ )
+ expected_exit = 1 if mutation else 0
+ if result.returncode != expected_exit:
+ raise RuntimeError(f"{name}: expected pytest exit {expected_exit}, got {result.returncode}")
+ count = check_junit(report, mutation=mutation)
+ if name == "baseline" and count < 2:
+ raise RuntimeError("The baseline did not execute the preservation controls")
+ return {"phase": name, "command": command, "exit_code": result.returncode, "cases": count}
+
+
+def main() -> int:
+ """Qualify the token before enabling the native prerequisite marker."""
+ parser = argparse.ArgumentParser(description=__doc__)
+ parser.add_argument("--root", type=Path, required=True)
+ parser.add_argument("--scratch", type=Path, required=True)
+ parser.add_argument("--expected-sid", required=True)
+ parser.add_argument("--expected-head", required=True)
+ args = parser.parse_args()
+ try:
+ root = args.root.resolve(strict=True)
+ scratch = args.scratch.resolve(strict=True)
+ for name in ("home", "temp", "cache", "appdata", "localappdata", "config", "data"):
+ (scratch / name).mkdir(exist_ok=True)
+ for key in tuple(os.environ):
+ if key.startswith("GIT_"):
+ os.environ.pop(key)
+ for scope in ("GLOBAL", "SYSTEM"):
+ config = scratch / f"{scope.lower()}.gitconfig"
+ config.write_bytes(b"")
+ os.environ[f"GIT_CONFIG_{scope}"] = str(config)
+ os.environ.update(
+ HOME=str(scratch / "home"),
+ USERPROFILE=str(scratch / "home"),
+ TMP=str(scratch / "temp"),
+ TEMP=str(scratch / "temp"),
+ UV_CACHE_DIR=str(scratch / "cache"),
+ APPDATA=str(scratch / "appdata"),
+ LOCALAPPDATA=str(scratch / "localappdata"),
+ XDG_CACHE_HOME=str(scratch / "cache"),
+ XDG_CONFIG_HOME=str(scratch / "config"),
+ XDG_DATA_HOME=str(scratch / "data"),
+ COVERAGE_FILE=str(scratch / ".coverage"),
+ HYPOTHESIS_STORAGE_DIRECTORY=str(scratch / "cache" / "hypothesis"),
+ PYTEST_ADDOPTS="",
+ PYTHONDONTWRITEBYTECODE="1",
+ )
+ tempfile.tempdir = str(scratch / "temp")
+ provenance = source_provenance(root, args.expected_head)
+ context = probe_native_context(scratch / "initial-capability", args.expected_sid)
+ os.environ["APM_WINDOWS_NATIVE_EXPECTED_SID"] = args.expected_sid
+ os.environ["APM_WINDOWS_NATIVE_STANDARD_USER"] = "1"
+ _rich_info("Native standard-user token confirmed; running fallback and mutation controls")
+ phases = [run_phase(root, scratch, phase) for phase in ("baseline", "mutation", "restored")]
+ if source_provenance(root, args.expected_head) != provenance:
+ raise RuntimeError("Source/interpreter provenance changed during acceptance")
+ proof = {"source": provenance, "context": context, "phases": phases, "status": "passed"}
+ (scratch / "native-proof.json").write_text(
+ json.dumps(proof, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n"
+ )
+ _rich_info(
+ "Native fallback passed; the old-precedence mutation failed and restoration passed"
+ )
+ return 0
+ except (OSError, RuntimeError, ValueError, subprocess.SubprocessError, ET.ParseError) as error:
+ _rich_error(f"Native Windows proof failed: {error}")
+ return 1
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/apm_cli/utils/git_env.py b/src/apm_cli/utils/git_env.py
index a9e7880971..df673bbc82 100644
--- a/src/apm_cli/utils/git_env.py
+++ b/src/apm_cli/utils/git_env.py
@@ -985,6 +985,15 @@ def _merge_parent_git_config_snapshot(
)
+def _symlink_entry_wins(current: GitConfigEntry | None, candidate: GitConfigEntry) -> bool:
+ """Decide whether ``candidate`` should replace the retained core.symlinks entry.
+
+ Command-scope intent always wins over any other scope; otherwise the
+ last entry seen wins, matching Git's own scope-precedence ordering.
+ """
+ return current is None or current.scope != "command" or candidate.scope == "command"
+
+
def _materialize_git_config_snapshot(
env: dict[str, str],
snapshot: _GitConfigSnapshot,
@@ -1005,8 +1014,15 @@ def _materialize_git_config_snapshot(
else None
)
retained: list[tuple[str, str]] = []
+ symlinks: GitConfigEntry | None = None
for entry in snapshot.entries:
normalized = entry.key.lower()
+ if normalized == "core.symlinks":
+ # Parent command entries can precede child file entries after
+ # merging. Keep command intent above Git init's capability result.
+ if _symlink_entry_wins(symlinks, entry):
+ symlinks = entry
+ continue
if entry.scope in {"local", "worktree"} and not _is_scope_sensitive_network_config(entry):
continue
if normalized == "include.path" or (
@@ -1030,6 +1046,9 @@ def _materialize_git_config_snapshot(
continue
retained.append((entry.key, entry.value))
+ if symlinks is not None:
+ retained.append(("core.symlinks", symlinks.value))
+
if auth_fence is not None:
if auth_fence.suppress_helpers:
retained.append(("credential.helper", ""))
diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py
index 7bf988fedd..0c7986c76e 100644
--- a/tests/integration/conftest.py
+++ b/tests/integration/conftest.py
@@ -231,6 +231,10 @@ def _is_windows() -> bool:
return _platform.system() == "Windows"
+def _is_windows_native_standard_user() -> bool:
+ return _is_windows() and os.environ.get("APM_WINDOWS_NATIVE_STANDARD_USER") == "1"
+
+
def _has_apm_binary() -> bool:
return _resolve_apm_binary() is not None
@@ -258,6 +262,10 @@ def _has_runtime(name: str) -> bool:
"APM_RUN_INTEGRATION_TESTS=1 not set",
),
"requires_windows": (_is_windows, "Windows required"),
+ "requires_windows_native_standard_user": (
+ _is_windows_native_standard_user,
+ "Capability-qualified native Windows standard-user runner required",
+ ),
"requires_apm_binary": (
_has_apm_binary,
"apm binary not found on PATH (set APM_BINARY_PATH or build via scripts/build-binary.sh)",
diff --git a/tests/integration/test_windows_native_symlink.py b/tests/integration/test_windows_native_symlink.py
new file mode 100644
index 0000000000..d13e8098cb
--- /dev/null
+++ b/tests/integration/test_windows_native_symlink.py
@@ -0,0 +1,104 @@
+"""Real denied-capability acceptance on the disposable Windows standard user."""
+
+from __future__ import annotations
+
+import json
+import os
+import subprocess
+from collections.abc import Callable, Iterator
+from pathlib import Path
+
+import pytest
+
+from apm_cli.utils import git_env
+from scripts.windows_native_symlink_probe_entry import MUTATION_FAILURE, probe_native_context
+from tests.unit.cache.test_git_symlink_config import _git
+from tests.unit.cache.test_git_symlink_config import config_env as config_env
+from tests.unit.cache.test_git_symlink_config import symlink_source as symlink_source
+
+pytestmark = [pytest.mark.component, pytest.mark.requires_windows_native_standard_user]
+
+
+@pytest.fixture
+def native_precedence_guard(
+ monkeypatch: pytest.MonkeyPatch, record_property: Callable[[str, object], None]
+) -> Iterator[None]:
+ """Remove only the local-over-inherited guard for the explicit negative run."""
+ mode = os.environ.get("APM_WINDOWS_NATIVE_MUTATION", "")
+ if mode not in {"", "drop-local-precedence"}:
+ raise ValueError("Unknown native symlink mutation")
+ calls = 0
+ changes: list[tuple[str, str, str, str, bool, bool]] = []
+ original_selection = git_env._symlink_entry_wins
+ if mode:
+
+ def without_local_precedence(
+ current: git_env.GitConfigEntry | None, candidate: git_env.GitConfigEntry
+ ) -> bool:
+ nonlocal calls
+ calls += 1
+ mutant = current is None or candidate.scope == "command"
+ correct = original_selection(current, candidate)
+ if current is not None and mutant != correct:
+ changes.append(
+ (
+ current.scope,
+ current.value,
+ candidate.scope,
+ candidate.value,
+ correct,
+ mutant,
+ )
+ )
+ return mutant
+
+ monkeypatch.setattr(git_env, "_symlink_entry_wins", without_local_precedence)
+ yield
+ if mode:
+ assert calls > 0, "The native mutation did not reach the production selection helper"
+ record_property("mutation_decisions", json.dumps(changes))
+ assert ("global", "true", "local", "false", True, False) in changes, (
+ "The mutation must actually reject native local false over inherited global true"
+ )
+
+
+@pytest.mark.usefixtures("native_precedence_guard")
+def test_native_standard_user_symlink_fallback(
+ tmp_path: Path,
+ config_env: dict[str, str],
+ symlink_source: Path,
+ record_property: Callable[[str, object], None],
+) -> None:
+ """Match native Git with global true, actual local false and no create privilege."""
+ context = probe_native_context(
+ tmp_path / "capability", os.environ["APM_WINDOWS_NATIVE_EXPECTED_SID"]
+ )
+ record_property("native_context", json.dumps(context, sort_keys=True))
+ global_path = Path(config_env["GIT_CONFIG_GLOBAL"])
+ original_global = global_path.read_bytes()
+ assert _git(config_env, "config", "--global", "--bool", "core.symlinks") == "true"
+ native = tmp_path / "native"
+ target = tmp_path / "apm"
+ _git(config_env, "clone", "--quiet", "--template=", str(symlink_source), str(native))
+ assert _git(config_env, "-C", str(native), "config", "--local", "--bool", "core.symlinks") == (
+ "false"
+ )
+ assert not (native / "AGENTS.md").is_symlink()
+ assert (native / "AGENTS.md").read_bytes() == b"CLAUDE.md"
+
+ try:
+ git_env.clone_git_worktree(str(symlink_source), target, env=config_env)
+ except subprocess.CalledProcessError as error:
+ if "unable to create symlink AGENTS.md" not in (error.stderr or ""):
+ raise
+ pytest.fail(f"{MUTATION_FAILURE}; Git exit {error.returncode}")
+
+ assert not (target / "AGENTS.md").is_symlink()
+ assert (target / "AGENTS.md").read_bytes() == (native / "AGENTS.md").read_bytes()
+ assert (target / "CLAUDE.md").read_bytes() == (native / "CLAUDE.md").read_bytes()
+ assert (target / "CLAUDE.md").read_text(encoding="utf-8") == "Package instructions\n"
+ assert _git(config_env, "-C", str(target), "ls-files", "--stage", "AGENTS.md").startswith(
+ "120000 "
+ )
+ assert global_path.read_bytes() == original_global
+ assert _git(config_env, "config", "--global", "--bool", "core.symlinks") == "true"
diff --git a/tests/unit/cache/test_git_symlink_config.py b/tests/unit/cache/test_git_symlink_config.py
new file mode 100644
index 0000000000..4b6c6cdd3d
--- /dev/null
+++ b/tests/unit/cache/test_git_symlink_config.py
@@ -0,0 +1,281 @@
+"""Real Git regression checks for checkout capability and config precedence."""
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+from apm_cli.utils import git_env
+
+pytestmark = [pytest.mark.component, pytest.mark.windows_compat]
+
+
+def _git(env: dict[str, str], *args: str, input: str | None = None) -> str:
+ """Run a bounded local Git command in the isolated test environment."""
+ return subprocess.run(
+ [git_env.get_git_executable(), *args],
+ env=env,
+ input=input,
+ capture_output=True,
+ text=True,
+ check=True,
+ timeout=30,
+ ).stdout.strip()
+
+
+@pytest.fixture
+def config_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, str]:
+ """Exclude ambient Git state without changing any real user config."""
+ for key in tuple(os.environ):
+ if key.startswith("GIT_"):
+ monkeypatch.delenv(key)
+ for scope in ("GLOBAL", "SYSTEM"):
+ config = tmp_path / f"{scope.lower()}.gitconfig"
+ config.touch()
+ monkeypatch.setenv(f"GIT_CONFIG_{scope}", str(config))
+ monkeypatch.setenv("GIT_AUTHOR_NAME", "Fixture")
+ monkeypatch.setenv("GIT_AUTHOR_EMAIL", "fixture@example.test")
+ monkeypatch.setenv("GIT_COMMITTER_NAME", "Fixture")
+ monkeypatch.setenv("GIT_COMMITTER_EMAIL", "fixture@example.test")
+ return dict(os.environ)
+
+
+@pytest.mark.parametrize("inherited_scope", ["system", "global"])
+@pytest.mark.parametrize(
+ ("inherited", "local_scope", "local", "command_source", "command", "expected"),
+ [
+ ("true", "local", "false", None, None, "false"),
+ ("false", "local", "true", None, None, "true"),
+ ("true", "worktree", "false", None, None, "false"),
+ ("true", "local", "false", "indexed", "true", "true"),
+ ("true", "local", "false", "parameters", "true", "true"),
+ ("false", "worktree", "true", "indexed", "false", "false"),
+ ("true", "local", None, None, None, "true"),
+ ("false", "local", None, None, None, "false"),
+ ],
+)
+def test_network_env_preserves_effective_symlink_setting(
+ tmp_path: Path,
+ config_env: dict[str, str],
+ inherited_scope: str,
+ inherited: str,
+ local_scope: str,
+ local: str | None,
+ command_source: str | None,
+ command: str | None,
+ expected: str,
+) -> None:
+ """A capability result beats inherited settings, but not command intent."""
+ config = config_env[f"GIT_CONFIG_{inherited_scope.upper()}"]
+ _git(config_env, "config", "--file", config, "core.symlinks", inherited)
+ _git(config_env, "config", "--file", config, "core.compression", "3")
+ original_config = Path(config).read_bytes()
+ repo = tmp_path / "repo"
+ _git(config_env, "init", "--quiet", "--template=", str(repo))
+ # Normalize the native init outcome so every precedence case runs on every OS.
+ _git(config_env, "-C", str(repo), "config", "core.symlinks", "true")
+ _git(config_env, "-C", str(repo), "config", "--unset", "core.symlinks")
+ if local_scope == "worktree":
+ _git(config_env, "-C", str(repo), "config", "extensions.worktreeConfig", "true")
+ if local is not None:
+ _git(config_env, "-C", str(repo), "config", f"--{local_scope}", "core.symlinks", local)
+ if command_source == "indexed":
+ assert command is not None
+ config_env.update(
+ GIT_CONFIG_COUNT="1", GIT_CONFIG_KEY_0="core.symlinks", GIT_CONFIG_VALUE_0=command
+ )
+ elif command_source == "parameters":
+ config_env["GIT_CONFIG_PARAMETERS"] = f"'core.symlinks={command}'"
+ query = ("-C", str(repo), "config", "--bool", "--get", "core.symlinks")
+ assert _git(config_env, *query) == expected
+
+ child = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo)
+
+ assert _git(child, *query) == expected
+ assert _git(child, "-C", str(repo), "config", "--get", "core.compression") == "3"
+ assert child["GIT_CONFIG_GLOBAL"] == os.devnull
+ assert child["GIT_CONFIG_SYSTEM"] == os.devnull
+ assert Path(config).read_bytes() == original_config
+
+
+@pytest.mark.parametrize(
+ "sequence",
+ [
+ ("true", "false", "true"),
+ ("false", "true", "false"),
+ ("true", "false"),
+ ("false", "true"),
+ ("true", "true", "false"),
+ ],
+)
+def test_network_env_preserves_last_repeated_command_value(
+ tmp_path: Path,
+ config_env: dict[str, str],
+ sequence: tuple[str, ...],
+) -> None:
+ """Repeated indexed command-scope values must resolve to the last one.
+
+ Guards against collapsing a repeated `GIT_CONFIG_KEY_N`/`VALUE_N` sequence
+ (e.g. true, false, true) to the first occurrence instead of matching real
+ Git's last-value-wins semantics for a single-valued setting.
+ """
+ repo = tmp_path / "repo"
+ _git(config_env, "init", "--quiet", "--template=", str(repo))
+ config_env["GIT_CONFIG_COUNT"] = str(len(sequence))
+ for index, value in enumerate(sequence):
+ config_env[f"GIT_CONFIG_KEY_{index}"] = "core.symlinks"
+ config_env[f"GIT_CONFIG_VALUE_{index}"] = value
+ query = ("-C", str(repo), "config", "--bool", "--get", "core.symlinks")
+ expected = sequence[-1]
+ assert _git(config_env, *query) == expected
+
+ child = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo)
+
+ assert _git(child, *query) == expected
+
+
+def test_network_env_symlink_precedence_preserves_auth_isolation(
+ tmp_path: Path,
+ config_env: dict[str, str],
+) -> None:
+ """Symlink precedence resolution must not resurrect scrubbed auth state.
+
+ Regression-traps the shared entry loop in
+ ``_materialize_git_config_snapshot``: a repeated command-scope
+ ``core.symlinks`` sequence is interleaved with a command-scope
+ ``credential.helper`` reset and a stale ``http.extraheader``. A bug that
+ over-broadly widened the symlinks continue/retention branch could just as
+ easily let the fenced auth entries pass through unfiltered.
+ """
+ repo = tmp_path / "repo"
+ _git(config_env, "init", "--quiet", "--template=", str(repo))
+ config_env["GIT_CONFIG_COUNT"] = "5"
+ config_env["GIT_CONFIG_KEY_0"] = "http.extraheader"
+ config_env["GIT_CONFIG_VALUE_0"] = "Authorization: Basic stale"
+ config_env["GIT_CONFIG_KEY_1"] = "core.symlinks"
+ config_env["GIT_CONFIG_VALUE_1"] = "true"
+ config_env["GIT_CONFIG_KEY_2"] = "credential.helper"
+ config_env["GIT_CONFIG_VALUE_2"] = ""
+ config_env["GIT_CONFIG_KEY_3"] = "core.symlinks"
+ config_env["GIT_CONFIG_VALUE_3"] = "false"
+ config_env["GIT_CONFIG_KEY_4"] = "credential.helper"
+ config_env["GIT_CONFIG_VALUE_4"] = "!stale-helper"
+
+ child = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo)
+
+ query = ("-C", str(repo), "config", "--bool", "--get", "core.symlinks")
+ assert _git(child, *query) == "false"
+
+ assert "GIT_HTTP_EXTRAHEADER" not in child
+ entries = {
+ (
+ child.get(f"GIT_CONFIG_KEY_{index}", ""),
+ child.get(f"GIT_CONFIG_VALUE_{index}", ""),
+ )
+ for index in range(int(child.get("GIT_CONFIG_COUNT", "0")))
+ }
+ assert ("credential.helper", "!stale-helper") not in entries
+ assert all(not value.lower().startswith("authorization:") for _, value in entries)
+
+
+@pytest.mark.parametrize(
+ ("parent", "child", "expected"),
+ [
+ ("true", None, "true"),
+ ("false", None, "false"),
+ ("true", "false", "false"),
+ ("false", "true", "true"),
+ ],
+)
+def test_isolated_child_preserves_parent_command_intent(
+ tmp_path: Path,
+ config_env: dict[str, str],
+ monkeypatch: pytest.MonkeyPatch,
+ parent: str,
+ child: str | None,
+ expected: str,
+) -> None:
+ """An auth-isolated child must not demote the caller's explicit setting."""
+ repo = tmp_path / "repo"
+ _git(config_env, "init", "--quiet", "--template=", str(repo))
+ _git(config_env, "-C", str(repo), "config", "core.symlinks", "false")
+ monkeypatch.setenv("GIT_CONFIG_COUNT", "1")
+ monkeypatch.setenv("GIT_CONFIG_KEY_0", "core.symlinks")
+ monkeypatch.setenv("GIT_CONFIG_VALUE_0", parent)
+ config_env.update(
+ GIT_CONFIG_COUNT="1",
+ GIT_CONFIG_KEY_0="core.symlinks" if child is not None else "credential.helper",
+ GIT_CONFIG_VALUE_0=child if child is not None else "",
+ )
+
+ result = git_env.git_network_env("https://example.test/org/repo.git", config_env, worktree=repo)
+
+ assert _git(result, "-C", str(repo), "config", "--bool", "core.symlinks") == expected
+
+
+@pytest.fixture
+def symlink_source(tmp_path: Path, config_env: dict[str, str]) -> Path:
+ """Commit a real Git symlink without needing OS symlink-create privileges."""
+ source = tmp_path / "source"
+ _git(config_env, "init", "--quiet", "--template=", str(source))
+ (source / "CLAUDE.md").write_text("Package instructions\n", encoding="utf-8")
+ _git(config_env, "-C", str(source), "add", "CLAUDE.md")
+ oid = _git(config_env, "-C", str(source), "hash-object", "-w", "--stdin", input="CLAUDE.md")
+ _git(
+ config_env,
+ "-C",
+ str(source),
+ "update-index",
+ "--add",
+ "--cacheinfo",
+ "120000",
+ oid,
+ "AGENTS.md",
+ )
+ _git(config_env, "-C", str(source), "commit", "--quiet", "-m", "Symlink fixture")
+ _git(config_env, "config", "--global", "core.symlinks", "true")
+ return source
+
+
+def test_clone_respects_unavailable_symlink_capability(
+ tmp_path: Path,
+ config_env: dict[str, str],
+ symlink_source: Path,
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Replay the non-admin Windows init result, then perform a real checkout."""
+ real_init = git_env._git_init_run
+
+ def init_without_symlinks(args: list[str], **kwargs: object) -> subprocess.CompletedProcess:
+ result = real_init(args, **kwargs)
+ if "init" in args:
+ _git(config_env, "-C", args[-1], "config", "core.symlinks", "false")
+ return result
+
+ monkeypatch.setattr(git_env, "_git_init_run", init_without_symlinks)
+ target = tmp_path / "clone"
+
+ git_env.clone_git_worktree(str(symlink_source), target, env=config_env)
+
+ assert not (target / "AGENTS.md").is_symlink()
+ assert (target / "AGENTS.md").read_text(encoding="utf-8") == "CLAUDE.md"
+ assert (target / "CLAUDE.md").read_text(encoding="utf-8") == "Package instructions\n"
+ assert _git(config_env, "config", "--global", "--get", "core.symlinks") == "true"
+
+
+def test_clone_matches_native_git_symlink_capability(
+ tmp_path: Path, config_env: dict[str, str], symlink_source: Path
+) -> None:
+ """Exercise actual platform detection without mocking Git or OS capability."""
+ native = tmp_path / "native"
+ target = tmp_path / "apm"
+ _git(config_env, "clone", "--quiet", "--template=", str(symlink_source), str(native))
+
+ git_env.clone_git_worktree(str(symlink_source), target, env=config_env)
+
+ assert (target / "AGENTS.md").is_symlink() == (native / "AGENTS.md").is_symlink()
+ assert (target / "AGENTS.md").read_bytes() == (native / "AGENTS.md").read_bytes()
+ assert _git(config_env, "-C", str(target), "ls-files", "--stage", "AGENTS.md").startswith(
+ "120000 "
+ )
diff --git a/tests/unit/cache/test_git_symlink_precedence.py b/tests/unit/cache/test_git_symlink_precedence.py
new file mode 100644
index 0000000000..a782e19d53
--- /dev/null
+++ b/tests/unit/cache/test_git_symlink_precedence.py
@@ -0,0 +1,32 @@
+"""Truth-table guard for the extracted configuration-selection predicate."""
+
+from __future__ import annotations
+
+import pytest
+
+from apm_cli.utils.git_env import GitConfigEntry, _symlink_entry_wins
+
+pytestmark = [pytest.mark.unit, pytest.mark.windows_compat]
+
+
+@pytest.mark.parametrize(
+ ("current_scope", "candidate_scope", "expected"),
+ [
+ (None, "local", True),
+ ("global", "local", True),
+ ("local", "command", True),
+ ("command", "command", True),
+ ("command", "local", False),
+ ("system", "global", True),
+ ],
+)
+def test_symlink_entry_wins_truth_table(
+ current_scope: str | None, candidate_scope: str, expected: bool
+) -> None:
+ current = (
+ GitConfigEntry(scope=current_scope, key="core.symlinks", value="true")
+ if current_scope is not None
+ else None
+ )
+ candidate = GitConfigEntry(scope=candidate_scope, key="core.symlinks", value="false")
+ assert _symlink_entry_wins(current, candidate) is expected
diff --git a/tests/unit/test_windows_native_symlink_probe.py b/tests/unit/test_windows_native_symlink_probe.py
new file mode 100644
index 0000000000..e96d801645
--- /dev/null
+++ b/tests/unit/test_windows_native_symlink_probe.py
@@ -0,0 +1,301 @@
+"""Simulated probe controls; only the hosted Windows job is native evidence."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+from types import SimpleNamespace
+from unittest.mock import Mock
+from xml.sax.saxutils import escape
+
+import pytest
+
+import apm_cli
+from scripts import windows_native_symlink_probe_entry as probe
+from tests.unit.cache.test_git_symlink_config import _git
+from tests.unit.cache.test_git_symlink_config import config_env as config_env
+
+pytestmark = pytest.mark.component
+
+SID = "S-1-5-21-1-2-3-1001"
+NATIVE_CASE = (
+ '{}'
+)
+
+
+def _report(path: Path, cases: str) -> Path:
+ path.write_text(f"{cases}", encoding="utf-8")
+ return path
+
+
+@pytest.mark.parametrize(
+ ("cases", "mutation", "message"),
+ [
+ ("", False, "exactly once"),
+ ('', False, "exactly once"),
+ (NATIVE_CASE.format("") * 2, False, "exactly once"),
+ (NATIVE_CASE.format(""), False, "without skips"),
+ (NATIVE_CASE.format(""), False, "without skips"),
+ (NATIVE_CASE.format("failed"), False, "must pass"),
+ (NATIVE_CASE.format(""), True, "fail exactly"),
+ (NATIVE_CASE.format("setup failed"), True, "unrelated"),
+ (
+ NATIVE_CASE.format(f"{escape(probe.MUTATION_FAILURE)}")
+ + '',
+ True,
+ "fail exactly",
+ ),
+ ],
+)
+def test_junit_rejects_nonproof_results(
+ tmp_path: Path, cases: str, mutation: bool, message: str
+) -> None:
+ with pytest.raises(ValueError, match=message):
+ probe.check_junit(_report(tmp_path / "result.xml", cases), mutation=mutation)
+
+
+@pytest.mark.parametrize("mutation", [False, True])
+def test_junit_accepts_only_expected_native_outcome(tmp_path: Path, mutation: bool) -> None:
+ content = f"{escape(probe.MUTATION_FAILURE)}" if mutation else ""
+ assert (
+ probe.check_junit(
+ _report(tmp_path / "result.xml", NATIVE_CASE.format(content)), mutation=mutation
+ )
+ == 1
+ )
+
+
+@pytest.fixture
+def simulated_token(monkeypatch: pytest.MonkeyPatch) -> dict[str, list[list[str]]]:
+ rows = {
+ "/user": [["machine\\fixture", SID]],
+ "/groups": [["Users", "Alias", "S-1-5-32-545", "Enabled"]],
+ "/priv": [["SeChangeNotifyPrivilege", "Bypass traverse", "Enabled"]],
+ }
+ monkeypatch.setattr(probe, "_whoami_rows", rows.__getitem__)
+ monkeypatch.setattr(
+ probe,
+ "ctypes",
+ SimpleNamespace(
+ windll=SimpleNamespace(shell32=SimpleNamespace(IsUserAnAdmin=lambda: False))
+ ),
+ )
+
+ def denied(target: Path, link: Path) -> None:
+ error = OSError("simulated privilege denial")
+ error.winerror = 1314
+ raise error
+
+ monkeypatch.setattr(probe, "os", SimpleNamespace(name="nt", symlink=denied))
+ return rows
+
+
+def test_simulated_denial_exercises_real_scratch_io(
+ tmp_path: Path, simulated_token: dict[str, list[list[str]]]
+) -> None:
+ context = probe.probe_native_context(tmp_path / "probe", SID)
+ assert context["sid"] == SID
+ assert context["ordinary_io"] is True
+ assert context["symlink_winerror"] == 1314
+ assert (tmp_path / "probe" / "ordinary.txt").read_bytes() == b"ordinary read/write succeeds"
+
+
+@pytest.mark.parametrize(
+ ("option", "rows", "message"),
+ [
+ ("/user", [["machine\\unexpected", "wrong"]], "does not match"),
+ ("/groups", [], "incomplete"),
+ ("/priv", [], "incomplete"),
+ ("/groups", [["broken"]], "incomplete"),
+ ("/groups", [["Administrators", "Alias", "S-1-5-32-544", "Deny only"]], "administrator"),
+ (
+ "/priv",
+ [["SeCreateSymbolicLinkPrivilege", "Create links", "Disabled"]],
+ "symlink privilege",
+ ),
+ ],
+)
+def test_token_rejects_unqualified_identity(
+ tmp_path: Path,
+ simulated_token: dict[str, list[list[str]]],
+ option: str,
+ rows: list[list[str]],
+ message: str,
+) -> None:
+ simulated_token[option] = rows
+ with pytest.raises(RuntimeError, match=message):
+ probe.probe_native_context(tmp_path / "probe", SID)
+ assert not (tmp_path / "probe").exists()
+
+
+@pytest.mark.parametrize("winerror", [5, None])
+def test_wrong_native_denial_is_not_accepted(
+ tmp_path: Path,
+ simulated_token: dict[str, list[list[str]]],
+ monkeypatch: pytest.MonkeyPatch,
+ winerror: int | None,
+) -> None:
+ error = OSError("simulated unrelated error")
+ error.winerror = winerror
+ monkeypatch.setattr(probe.os, "symlink", Mock(side_effect=error))
+ with pytest.raises(RuntimeError, match="Expected privilege denial"):
+ probe.probe_native_context(tmp_path / "probe", SID)
+
+
+def test_successful_link_creation_is_not_denied_capability(
+ tmp_path: Path,
+ simulated_token: dict[str, list[list[str]]],
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(probe.os, "symlink", lambda target, link: link.write_bytes(b"simulated"))
+ with pytest.raises(RuntimeError, match="Symlink creation succeeded"):
+ probe.probe_native_context(tmp_path / "probe", SID)
+ assert not (tmp_path / "probe" / "link.txt").exists()
+
+
+@pytest.fixture
+def simulated_source(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, Mock]:
+ root = tmp_path / "checkout"
+ source = root / "src" / "apm_cli"
+ source.mkdir(parents=True)
+ python = root / ".venv" / "Scripts" / "python.exe"
+ python.parent.mkdir(parents=True)
+ python.with_name("apm.exe").touch()
+ monkeypatch.setattr(apm_cli, "__file__", str(source / "__init__.py"))
+ monkeypatch.setattr(sys, "executable", str(python))
+ monkeypatch.setattr(probe, "get_git_executable", lambda: "resolved-git")
+ command = Mock(side_effect=["a" * 40 + "\n", ""])
+ monkeypatch.setattr(subprocess, "check_output", command)
+ monkeypatch.setenv("APM_BINARY_PATH", "prior-binary")
+ return root, command
+
+
+def test_provenance_binds_exact_checkout_and_interpreter(
+ simulated_source: tuple[Path, Mock],
+) -> None:
+ root, command = simulated_source
+ actual = probe.source_provenance(root, "a" * 40)
+ assert actual["head"] == "a" * 40
+ assert Path(actual["python"]).parent == root / ".venv" / "Scripts"
+ assert os.environ["APM_BINARY_PATH"] == actual["apm_binary"]
+ assert command.call_args_list[0].args[0] == [
+ "resolved-git",
+ "-c",
+ f"safe.directory={root}",
+ "-C",
+ str(root),
+ "rev-parse",
+ "HEAD",
+ ]
+ assert command.call_args_list[1].args[0][-3:] == [
+ "status",
+ "--porcelain",
+ "--untracked-files=no",
+ ]
+
+
+@pytest.mark.parametrize("failure", ["head", "dirty", "source", "python", "binary"])
+def test_provenance_rejects_wrong_source(
+ simulated_source: tuple[Path, Mock], monkeypatch: pytest.MonkeyPatch, failure: str
+) -> None:
+ root, command = simulated_source
+ if failure == "head":
+ command.side_effect = ["b" * 40, ""]
+ elif failure == "dirty":
+ command.side_effect = ["a" * 40, " M src/apm_cli/utils/git_env.py\n"]
+ elif failure == "source":
+ monkeypatch.setattr(apm_cli, "__file__", str(root / "elsewhere" / "__init__.py"))
+ elif failure == "python":
+ monkeypatch.setattr(sys, "executable", str(root / "global" / "python.exe"))
+ else:
+ (root / ".venv" / "Scripts" / "apm.exe").unlink()
+ with pytest.raises(RuntimeError):
+ probe.source_provenance(root, "a" * 40)
+ assert os.environ["APM_BINARY_PATH"] == "prior-binary"
+
+
+@pytest.mark.parametrize("pin_checkout_lf", [False, True])
+def test_provenance_survives_config_isolation_only_with_bound_checkout_eol(
+ tmp_path: Path,
+ config_env: dict[str, str],
+ monkeypatch: pytest.MonkeyPatch,
+ pin_checkout_lf: bool,
+) -> None:
+ """Real Git reproduces CRLF dirtiness when the parent config is removed."""
+ seed = tmp_path / "seed"
+ _git(config_env, "init", "--quiet", "--template=", str(seed))
+ source = seed / "src" / "apm_cli"
+ source.mkdir(parents=True)
+ (source / "__init__.py").write_bytes(b"fixture = True\n")
+ _git(config_env, "-C", str(seed), "add", "src")
+ _git(config_env, "-C", str(seed), "commit", "--quiet", "-m", "Fixture")
+ system_config = Path(config_env["GIT_CONFIG_SYSTEM"])
+ system_config.write_text("[core]\n autocrlf = true\n", encoding="utf-8")
+ clone_env = dict(config_env)
+ if pin_checkout_lf:
+ clone_env.update(
+ GIT_CONFIG_COUNT="1",
+ GIT_CONFIG_KEY_0="core.autocrlf",
+ GIT_CONFIG_VALUE_0="false",
+ )
+ root = tmp_path / "checkout"
+ _git(clone_env, "clone", "--quiet", "--template=", str(seed), str(root))
+ head = _git(config_env, "-C", str(root), "rev-parse", "HEAD")
+ checked_out = root / "src" / "apm_cli" / "__init__.py"
+ assert checked_out.read_bytes() == (
+ b"fixture = True\n" if pin_checkout_lf else b"fixture = True\r\n"
+ )
+ system_config.write_bytes(b"")
+ stamp = checked_out.stat().st_mtime_ns + 2_000_000_000
+ os.utime(checked_out, ns=(stamp, stamp))
+ python = root / ".venv" / "Scripts" / "python.exe"
+ python.parent.mkdir(parents=True)
+ python.with_name("apm.exe").touch()
+ monkeypatch.setattr(sys, "executable", str(python))
+ monkeypatch.setattr(apm_cli, "__file__", str(checked_out))
+ monkeypatch.setenv("APM_BINARY_PATH", "prior")
+ if pin_checkout_lf:
+ assert probe.source_provenance(root, head)["head"] == head
+ else:
+ with pytest.raises(RuntimeError, match="tracked acceptance source is dirty"):
+ probe.source_provenance(root, head)
+
+
+@pytest.mark.parametrize("phase", ["baseline", "mutation", "restored"])
+def test_phase_uses_pinned_python_and_inspects_actual_result(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, phase: str
+) -> None:
+ mutation = phase == "mutation"
+ body = f"{escape(probe.MUTATION_FAILURE)}" if mutation else ""
+ cases = NATIVE_CASE.format(body)
+ if phase == "baseline":
+ cases += ''
+ _report(tmp_path / f"{phase}.xml", cases)
+ command = Mock(return_value=subprocess.CompletedProcess([], int(mutation)))
+ monkeypatch.setattr(subprocess, "run", command)
+ monkeypatch.setenv("APM_WINDOWS_NATIVE_MUTATION", "stale")
+ actual = probe.run_phase(tmp_path, tmp_path, phase)
+ args = command.call_args.args[0]
+ assert args[:4] == [sys.executable, "-B", "-m", "pytest"]
+ assert args[-1] == probe.NATIVE_NODE
+ assert ("tests/unit/cache/test_git_symlink_config.py" in args) is (phase == "baseline")
+ assert command.call_args.kwargs["timeout"] == 180
+ assert command.call_args.kwargs["env"].get("APM_WINDOWS_NATIVE_MUTATION") == (
+ "drop-local-precedence" if mutation else None
+ )
+ assert actual["exit_code"] == int(mutation)
+ assert actual["cases"] == (2 if phase == "baseline" else 1)
+
+
+@pytest.mark.parametrize("exit_code", [1, 2, 5])
+def test_phase_rejects_nonpassing_exit(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, exit_code: int
+) -> None:
+ monkeypatch.setattr(
+ subprocess, "run", Mock(return_value=subprocess.CompletedProcess([], exit_code))
+ )
+ with pytest.raises(RuntimeError, match="expected pytest exit 0"):
+ probe.run_phase(tmp_path, tmp_path, "baseline")
diff --git a/tests/unit/test_windows_native_symlink_workflow.py b/tests/unit/test_windows_native_symlink_workflow.py
new file mode 100644
index 0000000000..8178679466
--- /dev/null
+++ b/tests/unit/test_windows_native_symlink_workflow.py
@@ -0,0 +1,63 @@
+"""Keep the privileged fixture isolated, exact-head, bounded and fail-closed."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from tests.workflow_contracts import load_workflow, workflow_job, workflow_step, workflow_step_index
+
+pytestmark = pytest.mark.component
+ROOT = Path(__file__).resolve().parents[2]
+WORKFLOW = ROOT / ".github" / "workflows" / "ci.yml"
+JOB = "windows-native-standard-user-symlink-gate"
+SCRIPT = "./scripts/windows_native_standard_user_symlink_gate.ps1"
+EXACT_HEAD = "${{ github.event.pull_request.head.sha || github.sha }}"
+
+
+def test_native_job_is_unconditional_bounded_and_read_only() -> None:
+ job = workflow_job(load_workflow(WORKFLOW), JOB)
+ assert job["runs-on"] == "windows-latest"
+ assert job["permissions"] == {"contents": "read"}
+ assert 0 < job["timeout-minutes"] <= 15
+ assert "if" not in job
+ assert not job.get("continue-on-error", False)
+ checkout = job["steps"][0]
+ assert checkout["uses"].split("@")[0] == "actions/checkout"
+ assert checkout["env"] == {
+ "GIT_CONFIG_COUNT": "1",
+ "GIT_CONFIG_KEY_0": "core.autocrlf",
+ "GIT_CONFIG_VALUE_0": "false",
+ }
+ assert checkout["with"] == {"ref": EXACT_HEAD, "persist-credentials": False}
+ assert workflow_step(job, "Install dependencies")["run"] == "uv sync --frozen --extra dev"
+
+
+def test_native_cleanup_and_evidence_survive_subject_failure() -> None:
+ job = workflow_job(load_workflow(WORKFLOW), JOB)
+ subject = workflow_step(job, "Prove native standard-user fallback")
+ assert subject["env"] == {"APM_NATIVE_EXPECTED_HEAD": EXACT_HEAD}
+ assert subject["run"] == f"{SCRIPT} -ExpectedHead $env:APM_NATIVE_EXPECTED_HEAD"
+ assert subject["shell"] == "pwsh"
+ assert 0 < subject["timeout-minutes"] <= 10
+ assert not subject.get("continue-on-error", False)
+ cleanup = workflow_step(job, "Restore owned native fixture")
+ assert cleanup["if"] == "always()"
+ assert cleanup["run"] == f"{SCRIPT} -CleanupOnly"
+ assert cleanup["shell"] == "pwsh"
+ assert 0 < cleanup["timeout-minutes"] <= 2
+ assert not cleanup.get("continue-on-error", False)
+ upload = workflow_step(job, "Retain native proof and restoration evidence")
+ assert upload["if"] == "always()"
+ assert upload["uses"].split("@")[0] == "actions/upload-artifact"
+ assert upload["with"]["path"] == "${{ runner.temp }}/apm-native-symlink-evidence/"
+ assert upload["with"]["if-no-files-found"] == "error"
+ assert workflow_step_index(job, subject["name"]) < workflow_step_index(job, cleanup["name"])
+ assert workflow_step_index(job, cleanup["name"]) < workflow_step_index(job, upload["name"])
+
+
+def test_native_script_is_in_lint_format_and_duplication_checks() -> None:
+ lint = workflow_job(load_workflow(WORKFLOW), "lint")
+ for name in ("Ruff lint", "Ruff format check", "Code duplication guardrail (pylint R0801)"):
+ assert "scripts/windows_native_symlink_probe_entry.py" in workflow_step(lint, name)["run"]