From cdef025d6eebde0279d9e1581c51828549a91604 Mon Sep 17 00:00:00 2001 From: danielmeppiel Date: Wed, 18 Mar 2026 17:06:42 +0100 Subject: [PATCH] fix: credential fill returns garbage token in tokenless CI environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GIT_ASKPASS=echo caused git credential fill to echo prompt text as the password value. In GH Actions (where GITHUB_TOKEN is not a default env var), this garbage token triggered locked-down git env, breaking all clone methods — even for public repos. Fixes: - GIT_ASKPASS='' (empty) instead of 'echo' — credential fill fails cleanly when no helper is configured - Token validation rejects whitespace, prompt fragments, >1024 chars Protection against future regressions: - Release validation: tokenless install+pack test (test_ghaw_compat) - CI workflow: gh-aw-compat job gates publish-pypi/homebrew using microsoft/apm-action@v1 with the just-released binary Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/build-release.yml | 33 +++++++- scripts/test-release-validation.sh | 62 ++++++++++++++- src/apm_cli/core/token_manager.py | 24 +++++- tests/test_token_manager.py | 112 ++++++++++++++++++++++++++++ 4 files changed, 225 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 8010408c..6da1d805 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -554,11 +554,38 @@ jobs: ./dist/apm-windows-x86_64.zip ./dist/apm-windows-x86_64.zip.sha256 + # GH-AW Compatibility Gate — validates the released binary works in the + # exact flow GitHub Agentic Workflows uses (isolated install + pack, no token). + # Gates publish-pypi and update-homebrew so broken versions don't reach stable distribution. + gh-aw-compat: + name: GH-AW Compatibility + needs: [create-release] + if: github.ref_type == 'tag' + runs-on: ubuntu-24.04 + steps: + - name: Install and pack with apm-action + uses: microsoft/apm-action@v1 + id: pack + with: + dependencies: | + - microsoft/apm-sample-package + isolated: 'true' + pack: 'true' + archive: 'true' + target: claude + apm-version: ${{ github.ref_name }} + working-directory: /tmp/gh-aw-compat-test + + - name: Verify bundle + run: | + test -f "${{ steps.pack.outputs.bundle-path }}" + echo "✅ GH-AW compatibility test passed" + # Publish to PyPI (only stable releases from public repo) publish-pypi: name: Publish to PyPI runs-on: ubuntu-latest - needs: [test, build, integration-tests, release-validation, create-release] + needs: [test, build, integration-tests, release-validation, create-release, gh-aw-compat] if: github.ref_type == 'tag' && needs.create-release.outputs.is_private_repo != 'true' && needs.create-release.outputs.is_prerelease != 'true' environment: name: pypi @@ -596,7 +623,7 @@ jobs: update-homebrew: name: Update Homebrew Formula runs-on: ubuntu-latest - needs: [test, build, integration-tests, release-validation, create-release, publish-pypi] + needs: [test, build, integration-tests, release-validation, create-release, gh-aw-compat, publish-pypi] if: github.ref_type == 'tag' && needs.create-release.outputs.is_private_repo != 'true' && needs.create-release.outputs.is_prerelease != 'true' permissions: contents: read @@ -661,7 +688,7 @@ jobs: update-scoop: name: Update Scoop Bucket runs-on: ubuntu-latest - needs: [test, build, integration-tests, release-validation, create-release, publish-pypi] + needs: [test, build, integration-tests, release-validation, create-release, gh-aw-compat, publish-pypi] # TODO: Enable once downstream repository and secrets are configured (see #88) if: false && github.ref_type == 'tag' && needs.create-release.outputs.is_private_repo != 'true' && needs.create-release.outputs.is_prerelease != 'true' permissions: diff --git a/scripts/test-release-validation.sh b/scripts/test-release-validation.sh index 588de900..1fc36e0b 100755 --- a/scripts/test-release-validation.sh +++ b/scripts/test-release-validation.sh @@ -343,6 +343,59 @@ test_basic_commands() { log_success "Basic commands work" } +# GH-AW compatibility test - replicates the exact flow gh-aw uses: +# 1. Install a public package in isolated mode (no token) +# 2. Pack for Claude target with archive +# This catches regressions like v0.8.1 where credential fill garbage +# broke public repo cloning in tokenless environments. +test_ghaw_compat() { + log_test "GH-AW Compatibility: tokenless isolated install + pack" + + local ghaw_dir="ghaw-compat-test" + mkdir -p "$ghaw_dir" + + # Run in a subshell with NO GitHub tokens — simulates gh-aw activation + # where apm-action does not pass GITHUB_TOKEN to the subprocess. + ( + unset GITHUB_TOKEN GITHUB_APM_PAT GH_TOKEN 2>/dev/null || true + cd "$ghaw_dir" + + echo "Running: apm install microsoft/apm-sample-package --isolated (no token)" + "$BINARY_PATH" install microsoft/apm-sample-package --isolated + local install_exit=$? + if [[ $install_exit -ne 0 ]]; then + echo "apm install failed with exit code $install_exit" + exit 1 + fi + + echo "Running: apm pack --target claude --archive" + "$BINARY_PATH" pack --target claude --archive + local pack_exit=$? + if [[ $pack_exit -ne 0 ]]; then + echo "apm pack failed with exit code $pack_exit" + exit 1 + fi + + # Verify a bundle was produced + if ls *.tar.gz 1>/dev/null 2>&1 || ls *.zip 1>/dev/null 2>&1; then + echo "Bundle archive produced successfully" + else + echo "No bundle archive found" + exit 1 + fi + ) + local subshell_exit=$? + + rm -rf "$ghaw_dir" 2>/dev/null || true + + if [[ $subshell_exit -ne 0 ]]; then + log_error "GH-AW compatibility test failed — public repo install/pack broken without token" + return 1 + fi + + log_success "GH-AW compatibility: tokenless install + pack works" +} + # Main test runner - follows exact README flow main() { echo "APM CLI Release Validation - Binary Isolation Testing" @@ -370,7 +423,7 @@ echo "" echo "Binary found and executable: $BINARY_PATH" local tests_passed=0 - local tests_total=5 # Prerequisites, basic commands, runtime setup, 2 hero scenarios + local tests_total=6 # Prerequisites, basic commands, gh-aw compat, runtime setup, 2 hero scenarios local dependency_tests_run=false # Add dependency tests to total if available and GITHUB token is present @@ -401,6 +454,12 @@ echo "" log_error "Basic commands test failed" fi + if test_ghaw_compat; then + ((tests_passed++)) + else + log_error "GH-AW compatibility test failed" + fi + if test_runtime_setup; then ((tests_passed++)) else @@ -448,6 +507,7 @@ echo "" echo " 1. Prerequisites (GITHUB_TOKEN) ✅" echo " 2. Binary accessibility ✅" echo " 3. Runtime setup (copilot) ✅" + echo " 4. GH-AW compatibility (tokenless install + pack) ✅" echo "" echo " HERO SCENARIO 1: 30-Second Zero-Config ✨" echo " - Run virtual package directly ✅" diff --git a/src/apm_cli/core/token_manager.py b/src/apm_cli/core/token_manager.py index 83df8fc5..2b7138be 100644 --- a/src/apm_cli/core/token_manager.py +++ b/src/apm_cli/core/token_manager.py @@ -50,6 +50,24 @@ def __init__(self, preserve_existing: bool = True): self.preserve_existing = preserve_existing self._credential_cache: Dict[str, Optional[str]] = {} + @staticmethod + def _is_valid_credential_token(token: str) -> bool: + """Validate that a credential-fill token looks like a real credential. + + Rejects garbage values that can appear when GIT_ASKPASS or credential + helpers return prompt text instead of actual tokens. + """ + if not token: + return False + if len(token) > 1024: + return False + if any(c in token for c in (' ', '\t', '\n', '\r')): + return False + prompt_fragments = ('Password for', 'Username for', 'password for', 'username for') + if any(fragment in token for fragment in prompt_fragments): + return False + return True + @staticmethod def resolve_credential_from_git(host: str) -> Optional[str]: """Resolve a credential from the git credential store. @@ -71,7 +89,7 @@ def resolve_credential_from_git(host: str) -> Optional[str]: capture_output=True, text=True, timeout=5, - env={**os.environ, 'GIT_TERMINAL_PROMPT': '0', 'GIT_ASKPASS': 'echo'}, + env={**os.environ, 'GIT_TERMINAL_PROMPT': '0', 'GIT_ASKPASS': ''}, ) if result.returncode != 0: return None @@ -79,7 +97,9 @@ def resolve_credential_from_git(host: str) -> Optional[str]: for line in result.stdout.splitlines(): if line.startswith('password='): token = line[len('password='):] - return token if token else None + if token and GitHubTokenManager._is_valid_credential_token(token): + return token + return None return None except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return None diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index 16e2aa1b..3b21a2e2 100644 --- a/tests/test_token_manager.py +++ b/tests/test_token_manager.py @@ -131,6 +131,118 @@ def test_git_terminal_prompt_disabled(self): call_env = mock_run.call_args.kwargs['env'] assert call_env['GIT_TERMINAL_PROMPT'] == '0' + def test_git_askpass_set_to_empty(self): + """GIT_ASKPASS is set to empty string (not 'echo') to prevent prompt echo.""" + mock_result = MagicMock(returncode=0, stdout="password=tok\n") + with patch('subprocess.run', return_value=mock_result) as mock_run: + GitHubTokenManager.resolve_credential_from_git('github.com') + call_env = mock_run.call_args.kwargs['env'] + assert call_env['GIT_ASKPASS'] == '' + + def test_rejects_password_prompt_as_token(self): + """Rejects 'Password for ...' prompt text echoed back by GIT_ASKPASS.""" + mock_result = MagicMock( + returncode=0, + stdout="password=Password for 'https://github.com': \n", + ) + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_git('github.com') is None + + def test_rejects_username_prompt_as_token(self): + """Rejects 'Username for ...' prompt text.""" + mock_result = MagicMock( + returncode=0, + stdout="password=Username for 'https://github.com': \n", + ) + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_git('github.com') is None + + def test_rejects_token_with_spaces(self): + """Rejects tokens containing spaces (likely prompt garbage).""" + mock_result = MagicMock( + returncode=0, + stdout="password=some garbage token value\n", + ) + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_git('github.com') is None + + def test_rejects_token_with_tabs(self): + """Rejects tokens containing tab characters.""" + mock_result = MagicMock( + returncode=0, + stdout="password=some\ttoken\n", + ) + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_git('github.com') is None + + def test_rejects_excessively_long_token(self): + """Rejects tokens longer than 1024 characters.""" + mock_result = MagicMock( + returncode=0, + stdout=f"password={'x' * 1025}\n", + ) + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_git('github.com') is None + + def test_accepts_valid_ghp_token(self): + """Accepts a normal GitHub PAT (ghp_ prefix).""" + mock_result = MagicMock( + returncode=0, + stdout="password=ghp_abcdefghijk1234567890abcdefghijk1234\n", + ) + with patch('subprocess.run', return_value=mock_result): + token = GitHubTokenManager.resolve_credential_from_git('github.com') + assert token == 'ghp_abcdefghijk1234567890abcdefghijk1234' + + def test_accepts_valid_gho_token(self): + """Accepts a GitHub OAuth token (gho_ prefix).""" + mock_result = MagicMock( + returncode=0, + stdout="password=gho_abc123def456\n", + ) + with patch('subprocess.run', return_value=mock_result): + token = GitHubTokenManager.resolve_credential_from_git('github.com') + assert token == 'gho_abc123def456' + + +class TestIsValidCredentialToken: + """Test _is_valid_credential_token validation.""" + + def test_empty_string_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token('') + + def test_none_coerced_invalid(self): + """None would fail the truthiness check (caller already guards this).""" + assert not GitHubTokenManager._is_valid_credential_token('') + + def test_whitespace_only_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token(' ') + + def test_normal_pat_valid(self): + assert GitHubTokenManager._is_valid_credential_token('ghp_abc123') + + def test_over_1024_chars_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token('a' * 1025) + + def test_exactly_1024_chars_valid(self): + assert GitHubTokenManager._is_valid_credential_token('a' * 1024) + + def test_password_for_prompt_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token( + "Password for 'https://github.com': " + ) + + def test_username_for_prompt_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token( + "Username for 'https://github.com': " + ) + + def test_newline_in_token_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token('tok\nen') + + def test_tab_in_token_invalid(self): + assert not GitHubTokenManager._is_valid_credential_token('tok\ten') + class TestGetTokenWithCredentialFallback: """Test get_token_with_credential_fallback method."""