From 60d0f2aa70e4713c32c3386339b47aaabbd66d21 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Fri, 24 Jul 2026 13:50:15 +0500 Subject: [PATCH 1/2] fix(auth): resolve az via shutil.which so azure-cli token works on Windows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AzureDevOpsAuth._acquire_via_az_cli runs subprocess.run with a bare "az". On Windows the Azure CLI is installed as az.cmd, and subprocess.run calls CreateProcess, which does not consult PATHEXT -- so a bare "az" fails with WinError 2 even after `az login`, and azure-cli token acquisition silently returns None (the OSError is swallowed). Resolve the executable with shutil.which("az") (which honors PATHEXT) before the call, mirroring the maintainer's own fix in integrations/base.py for the same CreateProcess/.cmd issue. `or "az"` preserves prior behavior (and the existing not-installed OSError path) when az is absent. POSIX is unaffected. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../authentication/azure_devops.py | 11 ++++- tests/test_authentication.py | 42 +++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py index 907f1b5e24..3026965988 100644 --- a/src/specify_cli/authentication/azure_devops.py +++ b/src/specify_cli/authentication/azure_devops.py @@ -5,6 +5,7 @@ import base64 import json as _json import os +import shutil import subprocess from typing import TYPE_CHECKING @@ -71,9 +72,17 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None: def _acquire_via_az_cli() -> str | None: """Run ``az account get-access-token`` and return the access token.""" try: + # Windows: ``subprocess.run`` calls ``CreateProcess``, which does + # not consult ``PATHEXT``, so a bare ``"az"`` (installed as + # ``az.cmd``) fails with ``WinError 2`` even after ``az login``. + # Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the + # ``.cmd`` shim works. On POSIX this is a harmless lookup that + # returns the same executable; ``or "az"`` preserves the prior + # behavior (and the existing OSError path) when ``az`` is absent. + az = shutil.which("az") or "az" result = subprocess.run( # noqa: S603, S607 [ - "az", + az, "account", "get-access-token", "--resource", diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 06523c6f26..3561a670a9 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -494,6 +494,48 @@ def test_resolve_token_azure_cli_failure_returns_none(self): with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result): assert AzureDevOpsAuth().resolve_token(entry) is None + def test_resolve_token_azure_cli_resolves_executable(self): + """The az executable is resolved via shutil.which before invocation, so + the .cmd/.bat shim on Windows (CreateProcess ignores PATHEXT) is used.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 0 + result.stdout = '{"accessToken": "tok"}' + with patch( + "specify_cli.authentication.azure_devops.shutil.which", + return_value=r"C:\Program Files\az\wbin\az.CMD", + ), patch( + "specify_cli.authentication.azure_devops.subprocess.run", + return_value=result, + ) as run: + assert AzureDevOpsAuth().resolve_token(entry) == "tok" + argv = run.call_args.args[0] + assert argv[0] == r"C:\Program Files\az\wbin\az.CMD" + assert argv[1:4] == ["account", "get-access-token", "--resource"] + + def test_resolve_token_azure_cli_falls_back_to_bare_az(self): + """When shutil.which finds nothing, fall back to the bare "az" so the + existing OSError-not-installed path is preserved.""" + from unittest.mock import patch, MagicMock + entry = AuthConfigEntry( + hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", + ) + result = MagicMock() + result.returncode = 0 + result.stdout = '{"accessToken": "tok"}' + with patch( + "specify_cli.authentication.azure_devops.shutil.which", + return_value=None, + ), patch( + "specify_cli.authentication.azure_devops.subprocess.run", + return_value=result, + ) as run: + assert AzureDevOpsAuth().resolve_token(entry) == "tok" + assert run.call_args.args[0][0] == "az" + def test_resolve_token_azure_cli_not_installed_returns_none(self): """azure-cli returns None when az is not installed.""" from unittest.mock import patch From 8816476db3403732647e2a062271aac6210faa52 Mon Sep 17 00:00:00 2001 From: jawwad-ali Date: Sat, 25 Jul 2026 10:20:53 +0500 Subject: [PATCH 2/2] fix(auth): require an absolute az path so the CWD cannot hijack the lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review catch on my own change: resolving with a bare `shutil.which("az") or "az"` widened an execution surface. On Windows shutil.which prepends the CURRENT DIRECTORY to the search path (unless NoDefaultCurrentDirectoryInExePath is set) AND honors PATHEXT, so a stray .\az.cmd / .\az.bat in the working directory resolves ahead of the real Azure CLI -- for a credential operation. Verified: with the real az scrubbed from PATH, shutil.which("az") returns '.\az.CMD'. Accept the resolution only when it is absolute; otherwise fall back to the bare "az" (which also preserves the existing not-installed OSError path). A legitimate install always resolves absolutely, so the Windows .cmd fix this PR exists for is unaffected. The not-installed and PATHEXT tests are extended with relative-result cases, all of which fail before this commit. Note: integrations/base.py resolves executables the same way; hardening that shared path is a separate concern and is left untouched here. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5 (1M context) --- src/specify_cli/authentication/azure_devops.py | 16 +++++++++++++--- tests/test_authentication.py | 13 ++++++++----- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py index 3026965988..91578bd418 100644 --- a/src/specify_cli/authentication/azure_devops.py +++ b/src/specify_cli/authentication/azure_devops.py @@ -77,9 +77,19 @@ def _acquire_via_az_cli() -> str | None: # ``az.cmd``) fails with ``WinError 2`` even after ``az login``. # Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the # ``.cmd`` shim works. On POSIX this is a harmless lookup that - # returns the same executable; ``or "az"`` preserves the prior - # behavior (and the existing OSError path) when ``az`` is absent. - az = shutil.which("az") or "az" + # returns the same executable. + # + # Require an ABSOLUTE result: on Windows ``shutil.which`` prepends + # the current directory to the search path (unless + # ``NoDefaultCurrentDirectoryInExePath`` is set), so a stray + # ``.\az.cmd`` in the working directory would otherwise be resolved + # ahead of the real Azure CLI and run for a credential operation. A + # legitimate install always resolves to an absolute path, so this + # costs nothing; falling back to the bare ``"az"`` preserves the + # prior behavior (and the existing OSError path) when ``az`` is + # absent. + resolved = shutil.which("az") + az = resolved if resolved and os.path.isabs(resolved) else "az" result = subprocess.run( # noqa: S603, S607 [ az, diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 3561a670a9..374cf0c798 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -516,9 +516,12 @@ def test_resolve_token_azure_cli_resolves_executable(self): assert argv[0] == r"C:\Program Files\az\wbin\az.CMD" assert argv[1:4] == ["account", "get-access-token", "--resource"] - def test_resolve_token_azure_cli_falls_back_to_bare_az(self): - """When shutil.which finds nothing, fall back to the bare "az" so the - existing OSError-not-installed path is preserved.""" + @pytest.mark.parametrize("which_result", [None, r".\az.CMD", "az.cmd", "./az"]) + def test_resolve_token_azure_cli_falls_back_to_bare_az(self, which_result): + """Fall back to the bare "az" when shutil.which finds nothing OR returns a + NON-ABSOLUTE path. On Windows shutil.which searches the current directory + first, so a stray .\\az.cmd must never be executed for a credential + operation; the bare name also preserves the not-installed OSError path.""" from unittest.mock import patch, MagicMock entry = AuthConfigEntry( hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli", @@ -528,13 +531,13 @@ def test_resolve_token_azure_cli_falls_back_to_bare_az(self): result.stdout = '{"accessToken": "tok"}' with patch( "specify_cli.authentication.azure_devops.shutil.which", - return_value=None, + return_value=which_result, ), patch( "specify_cli.authentication.azure_devops.subprocess.run", return_value=result, ) as run: assert AzureDevOpsAuth().resolve_token(entry) == "tok" - assert run.call_args.args[0][0] == "az" + assert run.call_args.args[0][0] == "az", which_result def test_resolve_token_azure_cli_not_installed_returns_none(self): """azure-cli returns None when az is not installed."""