Skip to content

feat(cli): add apm auth for git host credentials (#2788) - #2789

Open
Joni Oliveira (jonioliveira) wants to merge 16 commits into
microsoft:mainfrom
SupermodularAI:upstream/apm-auth
Open

feat(cli): add apm auth for git host credentials (#2788)#2789
Joni Oliveira (jonioliveira) wants to merge 16 commits into
microsoft:mainfrom
SupermodularAI:upstream/apm-auth

Conversation

@jonioliveira

Copy link
Copy Markdown

Draft implementation of the apm auth command proposed in #2788.

This is a draft, not a review request. The questions in #2788 are still open — in particular whether this belongs as its own verb or folded into apm doctor, and whether the gap is one worth closing at all. I opened this so there is running code to judge rather than a description of it. If the answer is apm doctor, or no, closing this costs nothing.

What it does

Answers three questions about one host, and nothing else:

$ apm auth github.com
[+] github.com: using credential from GH_TOKEN.
[i] Add --check to validate it against the API.

$ apm auth gitlab.com --check
[!] gitlab.com: the credential from GITLAB_TOKEN was rejected (HTTP 401).
    An OAuth session token (e.g. from 'glab auth login') works for git but
    not for the REST API -- you need a personal access token.

$ eval "$(apm auth gitlab.com --export)"
  1. Which credential does APM resolve, and from where — names the source, so TOKEN_PRECEDENCE becomes observable rather than documented.
  2. Does it work (--check) — validates against the REST API, since a token git clone accepts is not automatically one the API accepts.
  3. If there is none — opens the host's token page with name and scopes prefilled, accepts a paste.

Scope

Additive. No changes to AuthResolver, classify_host, or token_manager, so it should not interact with the auth work in flight (#2681, #2673, #2610).

File
src/apm_cli/commands/auth.py the command
src/apm_cli/cli.py +3 lines, registration
docs/src/content/docs/reference/cli/auth.md reference page
docs/src/content/docs/reference/index.md +1 line, index entry
tests/unit/test_auth_command.py 37 tests

Design notes

It does not save the token. AuthResolver reads only env vars and credential helpers, so writing to ~/.apm/config.json would be inert — APM would never read it back. It prints the export line instead.

--export reserves stdout for the export line alone, so eval "$(...)" is safe. That needs two layers: sys.stdout redirected to stderr for narration, and fd 1 redirected, because subprocesses (webbrowser.open, gh auth token, git credential fill) inherit fd 1 and would otherwise write into the caller's eval. There is a regression test using a real subprocess — CliRunner captures sys.stdout and structurally cannot see fd 1.

--check is tri-state, not pass/fail. Rejected (401, or 403 on a normal PAT) advises minting a new token. Unreachable, 5xx, or a GitHub App installation token keeps the credential and exits 0. This matters in CI: Actions' GITHUB_TOKEN is a ghs_ token with no user context, so GET /user answers 403 even though it reads repository contents fine — a pre-flight must not fail that build.

It never mutates a credential store. A shadowing macOS keychain entry is reported, never erased, and only when a credential helper was actually consulted — env vars are resolved first, so a keychain entry cannot be shadowing anything when the token came from GITHUB_APM_PAT.

Hosts that classify as generic exit non-zero, with an error naming GITHUB_HOST / GITLAB_HOST as the fix. I deliberately did not teach classify_host to infer GHES from a bare hostname — it is a shared authority consumed by install, marketplace, and deps, and #2594's transport policy suggests routing generic hosts through native helpers without platform tokens is intended behavior rather than a gap.

Testing

37 unit tests. Full unit suite passes on this branch (21081 passed); lint, format, and scripts/lint-architecture-boundaries.sh are clean. Rebased on current main.

One thing that stands alone

GH_TOKEN is in TOKEN_PRECEDENCE["modules"] but was missing from the documented resolution order, so a user with only GH_TOKEN set reads the docs as saying it is unused. Fixed here in all three places it is stated, with a test deriving the expected vars from TOKEN_PRECEDENCE so they cannot drift again. Happy to split that into its own PR regardless of what happens to the rest.

Closes #2788 if merged.

Automates what a new joiner otherwise runs by hand: prove there is a
usable credential for the marketplace host, register the marketplace,
and smoke-test that it is browsable.

Ported from an external bash setup script, but deliberately not a 1:1
transcription. The script wires `glab` in as a git credential helper and
evicts the shadowing macOS osxkeychain entry, because an external script
can only reach a token through `git credential fill`. Inside APM that
workaround is unnecessary: AuthResolver consults GITLAB_APM_PAT /
GITLAB_TOKEN *before* any credential helper, so the keychain-shadowing
problem the script fights simply does not arise. Dropping it also avoids
introducing `glab` as a new external dependency (it appears nowhere else
in the codebase).

Two behaviours are deliberately softened from the original:

- A shadowing keychain entry is reported with the exact command to clear
  it, never erased. Silently mutating a global credential store is not
  something a package manager should do on the user's behalf.
- No global git config is written.

Registration and browsing delegate to `marketplace add` / `browse` via
ctx.invoke, so source parsing, ref handling and error rendering stay in
one place. Token verification reuses AuthResolver.gitlab_rest_headers
rather than hand-rolling the PRIVATE-TOKEN convention, and hits the real
REST endpoint: a token git accepts is not necessarily one the API
accepts (an OAuth session token 401s here).

SOURCE is a required argument, so the command carries no organisation
specific defaults and works for any marketplace.

Honours APM_NON_INTERACTIVE / CI, exiting with actionable guidance
instead of hanging on a prompt.

27 unit tests; the registration-failure and token-verification guards
were mutation-checked to confirm they fail when the logic is broken.
Satisfies the CLI docs contract (test_cli_docs_contract), which requires
every public command to have a reference page linked from the reference
index. Documents the credential model in particular: APM reads
GITLAB_APM_PAT / GITLAB_TOKEN before any git credential helper, the
REST-vs-git token distinction that makes an OAuth session token fail,
and the non-interactive contract for CI.
`run_enroll` pre-checks credentials and, on a miss, sets the verified
token into os.environ so the subsequent `marketplace add` picks it up.
AuthResolver caches resolutions, so that hand-off is only correct
because resolve_existing_token builds a throwaway resolver whose cache
dies with it.

Verified empirically: a retained resolver keeps serving its cached
token=None after the env var is set, while a fresh instance resolves the
new token. So hoisting the resolver to a shared/module-level instance —
an entirely reasonable-looking optimisation — would make registration
fail immediately after telling the user "Token verified against the
GitLab API".

Nothing else covered this: every flow test patches resolve_existing_token
and verify_token, and the local-path E2E skips the credential path
entirely. Adds a regression test that fails under exactly that refactor
(confirmed by mutation), plus a comment at the call site.

Also documents why registered[-1] is the just-added marketplace:
add_marketplace() filters any same-name entry, then appends.
Runs every `apm enroll` check that does not need a private-repo
credential: registration, happy path, idempotent re-run, alias recovery
without --name, the CI guard, and input rejection.

Sandboxes HOME because CONFIG_DIR is hardcoded to ~/.apm with no env
override, so an unsandboxed run would write into the reviewer's real
marketplaces.json alongside their existing entries. The script asserts
afterwards that no sandbox entry leaked there.

Uses a subshell rather than `env -u` to clear the token vars: macOS env
requires -u before assignments and GNU env does not, so `env -u` fails
with exit 127 on macOS and silently looks like a product failure.
The credential pre-check previously ran only for GitLab, so enrolling on a
private GitHub marketplace skipped it entirely and surfaced the raw
downstream error -- "No marketplace.json found ... Checked: <3 paths>" --
when the real cause was that you could not authenticate. Misleading, and
the whole point of the command.

Verification is now host-generic and delegates URL/header construction to
marketplace.client's existing builders (_github_contents_url /
_github_headers / _gitlab_file_raw_url / _gitlab_headers) rather than
hand-rolling a second convention. That keeps the probe from drifting away
from what registration actually does -- a probe that greenlights a token
registration then rejects is worse than no probe -- and it inherits GHES
support free, since those builders derive the API base from HostInfo
instead of hardcoding a public hostname.

Also fixes a latent defect the GitHub work exposed: the probe hardcoded
.claude-plugin/marketplace.json, so any marketplace using one of the other
two supported locations was reported unreadable. It now walks
_MARKETPLACE_PATHS, the same candidates the real fetch tries, and prefers a
401/403 over a 404 when reporting -- "you cannot authenticate" is the
actionable message, and 404 is what a host returns for a private repo it
will not admit exists. This was already wrong for GitLab; our marketplaces
just happen to use the one path it probed.

Host-specific now: env var (GITHUB_APM_PAT vs GITLAB_APM_PAT), scopes
(`repo` vs read_repository,read_api), token-page URL, and the 401 hint
(GitLab's OAuth-vs-REST distinction does not apply to GitHub).

One behaviour change worth flagging: a failed credential check on an
anonymous-capable host is now a warning, not a hard stop. A public
marketplace needs no token, and distinguishing public from private would
need an anonymous probe -- but GitHub caps unauthenticated requests at
60/hour per IP and returns 403 when exhausted, indistinguishable from a
permissions 403. Gating on that would fail working public enrollments for
unrelated reasons (observed live: the probe exhausted the quota during
testing). So it warns and lets `marketplace add`, the actual authority,
decide. Hosts with no anonymous path still exit 1.

40 unit tests (was 28). Full suite 19,998 passing; the 2 failures are
pre-existing. Mutation-checked both new guards: routing GitHub through the
GitLab builders, and probing only the first candidate path, each turn the
relevant test red. Verified live against github/awesome-copilot
("Existing credential works (source: GITHUB_APM_PAT)", 136 plugins, exit 0)
and against a private repo for the 401/404 diagnoses.
enroll was re-implementing work marketplace.client already does.
_auto_detect_path walks every candidate manifest path, and _fetch_via_api
maps 404 to "try the next path" and raises on anything else -- so the
pre-flight probe here duplicated that logic and could drift from it. A
probe that greenlights a token registration then rejects is worse than no
probe at all.

Removes verify_token, _probe_manifest and _is_publicly_readable. The
credential step now asks only whether a token *exists*; whether it works is
decided by the fetch during registration, which is the authority.

This also dissolves the problem that forced last commit's warn-and-continue
compromise. Deciding whether to block required knowing if a marketplace was
public, which needed an anonymous probe -- and GitHub caps unauthenticated
requests at 60/hour per IP, returning a 403 indistinguishable from a
permissions failure. With no probe there is nothing to rate-limit. A
missing token stays non-fatal (a public marketplace needs none) and
registration reports the truth either way.

Renames --skip-verify to --no-token: there is no verification left to skip,
only the credential step.

Net -257 lines across command and tests, and the multi-path defect fixed
last commit disappears with the code that had it -- it only ever existed
because this file hardcoded a manifest path that _auto_detect_path was
already handling.

Documents one limitation found while validating, inherited not introduced:
an invalid token against a private GitHub repo reports "No marketplace.json
found" rather than an auth failure, because try_with_fallback retries
anonymously and GitHub 404s a private repo it will not admit exists,
swallowing the 401. `apm marketplace add` alone behaves identically --
verified against the released 0.26.0 binary -- so it is an APM-wide
diagnostic gap in the fetch path, not something enrolment can fix by
re-probing. Worth a separate issue.

28 unit tests; full suite 19,986 passing (same 2 pre-existing failures).
The no-probe guarantee is mutation-checked: reintroducing any network call
in the credential step turns two tests red.
validate-enroll.sh is offline and pass/fail; this one makes real calls and
is meant to be read. It reports what the running machine's credentials
actually do per host, then enrols against a real marketplace on each.

Encodes two things that cost time to discover by hand:

- `glab auth token` is not a subcommand. It prints the help text for `glab
  auth` to stdout, so `$(glab auth token)` silently yields a 1400-character
  "token" and every request fails with a misleading error. The credential
  helper (`glab auth git-credential get`) is the working path.
- The credential glab returns is an OAuth session token: PRIVATE-TOKEN gets
  401, Bearer gets 200. APM sends PRIVATE-TOKEN, so a glab session does NOT
  authenticate a GitLab marketplace -- exactly the OAuth-vs-PAT distinction
  the command exists to walk users through. The script detects this and says
  so instead of leaving you with "No marketplace.json found".

Reads both CLIs' tokens before swapping HOME, since sandboxing hides
~/.config/gh and glab's config and would otherwise report "no credential"
for a machine that has both.

Also documents that "No marketplace.json found ... Checked: <3 paths>" can
mean bad auth rather than a missing file, since GitHub and GitLab 404 a
private repo rather than admit it exists.
Scope correction from review: enroll bundled three things -- credentials,
marketplace add, and browse -- when only the credential part was missing
from APM. Registration and browsing already have commands that do them
better, so bundling them added a wrapper without adding capability.

`apm auth <host>` does the one job: report which credential APM resolves for
a host, and walk you through creating a token if there is none. It registers
nothing and installs nothing.

Why it prints an export line rather than saving the token: a child process
cannot mutate its parent shell's environment, and AuthResolver reads
credentials only from env vars and git credential helpers -- never from
~/.apm/config.json (verified). Writing the token into APM's own config would
therefore be inert. `eval "$(apm auth gitlab.com --export)"` makes it a
one-liner; under --export stdout carries only the export line and all
narration goes to stderr, which is what makes that eval safe.

That output is eval'd, so the token is POSIX-escaped ('\'') -- a token
containing a single quote would otherwise close the quoting and let the
remainder run as shell code. Mutation-tested: removing the escape turns the
test red.

Validation is now opt-in behind --check, and hits the identity endpoint
rather than a repository, so the answer does not depend on access to any
particular project. It costs a round trip, and unauthenticated GitHub
requests are capped at 60/hour per IP, so it should not be the default.
--check is what surfaces the GitLab OAuth-vs-PAT distinction: a `glab auth
login` session token is valid for git clone but 401s against the REST API
that marketplace lookups use. Verified live against gitlab.com -- the
message now names that cause instead of leaving a confusing downstream
error.

Keeps from enroll: host-specific env var / scopes / token-page URL (GitHub,
GHES and GitLab each get the right one), the report-don't-erase treatment of
a shadowing macOS keychain entry, the APM_NON_INTERACTIVE / CI guard, and
the throwaway-AuthResolver invariant (its cache is per instance, so a
retained resolver would serve a stale miss).

26 unit tests; full suite 19,984 passing (same 2 pre-existing failures).
Drops validate-enroll.sh and try-enroll.sh -- they tested the bundled flow.
Addresses the seven review findings on #1.

`--export` promised that stdout carries only the export line, but
`contextlib.redirect_stdout` rebinds `sys.stdout` and leaves fd 1 pointing
at the terminal. Subprocesses inherit that fd, so `webbrowser.open` (and
`gh auth token`, and `git credential fill`) could write ahead of the export
line, straight into the caller's `eval` -- with an unquoted `&` in the token
page URL, forking it into the background. Guard fd 1 for the whole run and
restore it in `finally`.

The same wrapper buffered narration and replayed it only after `run_auth`
returned, so "paste it below" and the token URL printed *after* the prompt
they were meant to precede -- and were lost entirely on Ctrl-C. Stream to
stderr instead; the buffer, and the missing try/finally, both go away.

`check_token` returned a two-state answer that could not say "I don't know",
so an unreachable API read as a rejected credential and told the user to mint
a replacement that fixes nothing. It now returns ok/rejected/indeterminate,
and only a real rejection triggers the mint path. That also stops CI failing
on Actions' own `GITHUB_TOKEN`: a `ghs_` installation token has no user
context, so `GET /user` answers 403 even though it reads repo contents fine.

The keychain-shadowing notice fired even when the token came from an env var
-- which is consulted before any helper, so nothing was being shadowed. It
advised erasing the credential plain `git push` depends on. Gate it on the
helper having actually been consulted.

`ghe.corp.example` is the example the docs offer, and it exits 1 until
`GITHUB_HOST` is set. Classification is a shared authority, so rather than
guess from the hostname, the error now names the variable to set.

Docs: add the missing `GH_TOKEN` to the resolution chain (it is in
`TOKEN_PRECEDENCE["modules"]`), state the self-managed env-hint prerequisite,
and document the three `--check` outcomes.

Tests: the existing export-mode tests pass through `CliRunner`, which
captures `sys.stdout` and structurally cannot see fd 1 -- which is why the
bug survived them. Adds a real subprocess test with `BROWSER=/bin/echo`,
confirmed to fail against the old implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CI branch: an Actions GITHUB_TOKEN that cannot be validated must still
emit the export line rather than being dropped by the short-circuit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ther

The resolution chain is stated in three places -- the docs table, the module
docstring, and the --help epilog. The review caught GH_TOKEN missing from the
first; it was missing from all three. The epilog is the copy users actually
hit, so it mattered most.

Adds tests that derive the expected vars from TOKEN_PRECEDENCE["modules"]
rather than hardcoding them, so the next variable added to the resolver fails
these instead of silently drifting out of the docs again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An incomplete JSON fragment (truncated mid-key, unparseable) that was swept
into 96a4208 by a broad 'git add -A'. Unreferenced, not present upstream,
and unrelated to apm auth.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jonioliveira

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="supermodular.ai"

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

There are CI-relevant test URL assertion patterns and a shell-snippet quoting issue that should be fixed to satisfy repository security/testing conventions.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Adds a new apm auth CLI command to surface which credential APM resolves for a given git host, optionally validate it against the host REST API, and support a shell-safe --export mode for eval.

Changes:

  • Introduces src/apm_cli/commands/auth.py implementing apm auth with --check, --export, and interactive remediation.
  • Wires the new command into the CLI entrypoint and adds a reference docs page + index entry.
  • Adds a comprehensive unit test suite covering resolution, tri-state checking behavior, interactive prompting, and export-mode stdout isolation.
File summaries
File Description
src/apm_cli/commands/auth.py New apm auth command implementation, including token checking and export-safe output handling.
src/apm_cli/cli.py Registers the auth command and adds it to the CLI epilog workflow list.
docs/src/content/docs/reference/cli/auth.md New CLI reference page documenting usage, options, and behavior.
docs/src/content/docs/reference/index.md Adds auth to the reference index table.
tests/unit/test_auth_command.py New unit tests covering host mapping, check semantics, export-mode behavior, and doc/help drift prevention.
Review details

Suppressed comments (2)

tests/unit/test_auth_command.py:90

  • This test currently asserts the request URL via raw substring checks. Parse the URL and assert on hostname/path instead to align with the repo’s URL-assertion convention.
    def test_ghes_uses_the_enterprise_api_base(self):
        with patch("requests.get") as get:
            get.return_value = MagicMock(status_code=200)
            check_token("ghp_x", "ghe.corp.example", "ghes")
        assert "api.github.com" not in get.call_args[0][0]

tests/unit/test_auth_command.py:70

  • Avoid substring assertions against request URLs in tests; parse the called URL and assert on components (hostname/path) to satisfy the repo’s URL-assertion contract and avoid CodeQL substring-url findings.
        assert get.call_args.kwargs["headers"]["Authorization"] == "token ghp_x"
        assert "api.github.com/user" in get.call_args[0][0]

  • Files reviewed: 5/5 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +371 to +376
logger.warning(
f"A macOS keychain entry for {host} may be shadowing newer "
f"credentials. If authentication keeps failing, clear it with:\n"
f" printf 'protocol=https\\nhost={host}\\n\\n' | "
f"git credential-osxkeychain erase"
)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72f78bf — the host is now a printf argument via shlex.quote rather than interpolated into the format string.

Worth noting for the record that a quote-bearing host can't actually reach this line: AuthResolver.classify_host matches self-managed hosts exactly against GITHUB_HOST / GITLAB_HOST / APM_GITLAB_HOSTS, so git.corp'x.com lands in generic and run_auth returns 1 at the if not env_var guard, well before the shadowing warning. So this wasn't reachable in practice.

Fixing it anyway, because the shape you're pointing at is the right one for a second independent reason: % and \ are interpreted only in printf's format, not in its arguments. Moving the host out of the format removes a caller-influenced value from a position where escape sequences are live. And shlex.quote("github.com") returns the string bare, so the common-case snippet a user copies is unchanged:

printf 'protocol=https\nhost=%s\n\n' github.com | git credential-osxkeychain erase

Verified by execution that both a plain and a quote-bearing host produce exactly the protocol=https\nhost=<host>\n\n payload git credential-osxkeychain erase expects.

Comment thread tests/unit/test_auth_command.py Outdated
Comment on lines +42 to +56
def test_github_token_page(self):
url = token_page_url("github.com", "github", "apm-test")
assert url.startswith("https://github.com/settings/tokens/new")
assert "scopes=repo" in url

def test_gitlab_token_page(self):
url = token_page_url("gitlab.com", "gitlab", "apm-test")
assert "/-/user_settings/personal_access_tokens" in url
assert "read_repository" in url

def test_ghes_page_stays_on_the_enterprise_host(self):
"""Hardcoding github.com would send enterprise users to the wrong site."""
url = token_page_url("ghe.corp.example", "ghes", "apm-test")
assert url.startswith("https://ghe.corp.example/settings/tokens/new")
assert "github.com" not in url

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72f78bf, and swept wider than the three lines flagged here.

.github/instructions/tests.instructions.md is explicit that this fails CI via py/incomplete-url-substring-sanitization regardless of whether the asserted value is bounded, so I converted every URL assertion in the file rather than just the inline-flagged ones — including the two listed as suppressed comments (lines 70 and 90), which would otherwise have come back on the next review.

The parsed assertions are also strictly stronger than the substrings they replace, which is the part I'd flag as the real win:

  • assert "scopes=repo" in url passes for scopes=repository. parse_qs(url.query)["scopes"] == ["repo"] does not.
  • assert "github.com" not in url passes for a look-alike such as github.com.attacker.example. url.hostname == "ghe.corp.example" does not.
  • assert "api.github.com" not in get.call_args[0][0] only asserted an absence; it now pins /api/v3/user on the enterprise host positively, so a GHES request silently routed to a third host would fail the test.

I used direct urlparse component assertions rather than the _printed_urls helper from tests/unit/test_mcp_command.py: that helper extracts URLs from a printed text blob, whereas these tests already hold a single bare URL, and the instructions doc sanctions component-level checks for exactly that case. Happy to switch to the helper if you'd rather have one idiom across the suite.

Comment on lines +278 to +286
def test_generic_host_error_names_the_env_var_to_set(self):
"""'host class generic' is not actionable; GITHUB_HOST=<host> is.

ghe.corp.example is the example the docs offer, and it lands in
'generic' until its env hint is set.
"""
with patch("apm_cli.commands.auth.resolve_existing_token", return_value=(None, "none")):
result = self.runner.invoke(auth, ["ghe.corp.example"])
assert result.exit_code == 1

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 72f78bf — confirmed as a real gap, and reproduced before fixing.

ghe.corp.example is the exact value the docs tell users to export as GITHUB_HOST, so this was well-placed. With it set, the test fails on the assertion rather than on anything to do with the code:

$ GITHUB_HOST=ghe.corp.example uv run --extra dev pytest tests/unit/test_auth_command.py -q
FAILED test_generic_host_error_names_the_env_var_to_set
E  assert 'GITHUB_HOST=ghe.corp.example' in "... Set GITHUB_APM_PAT to a token with scopes 'repo'."

The host classifies as ghes instead of generic, so the command takes the success path and never emits the error the test is about.

The vars are now cleared for that one invocation via CliRunner.invoke(env={...: None}), matching the existing use in tests/unit/test_outdated_phase3w5.py. Scoping it to the invocation keeps the reason legible at the call site — the test asserts a generic classification, which only holds while no env hint claims the host.

I checked whether the file's other runner.invoke calls share the exposure and they don't: github.com / gitlab.com resolve by built-in rules that take precedence over the env hints, so the suite still passes under GITHUB_HOST=gitlab.com APM_GITLAB_HOSTS=github.com. Only the generic case was environment-dependent, so I left the rest alone rather than adding blanket clearing.

Copilot review on microsoft#2789.

Test URL assertions now parse with ``urllib.parse`` and compare on
components, per ``.github/instructions/tests.instructions.md``: substring
assertions against a URL are the same code shape as a security-critical
sanitiser check, so CodeQL flags them as
``py/incomplete-url-substring-sanitization`` and fails CI regardless of
whether the asserted value is bounded.

The parsed form is also a stronger assertion than the substring it
replaces. ``"scopes=repo" in url`` accepts ``scopes=repository``;
``parse_qs(...)["scopes"] == ["repo"]`` does not. ``"github.com" not in
url`` accepts a look-alike such as ``github.com.attacker.example``;
``hostname == "ghe.corp.example"`` does not. The GHES API test now pins
``/api/v3/user`` positively rather than only asserting api.github.com is
absent.

``printf 'protocol=https\nhost={host}\n\n'`` interpolated the host inside
a single-quoted shell string, so a host bearing a quote would have
produced an unsafe copy/paste snippet. Such a host cannot actually reach
this line -- env hints are matched exactly, so it classifies as 'generic'
and exits 1 earlier -- but passing the host as a printf *argument* via
``shlex.quote`` is correct regardless, and keeps ``%``/``\`` escapes out
of a caller-influenced format string. ``shlex.quote("github.com")``
returns it bare, so the common-case snippet is unchanged.

The generic-host test asserted a 'generic' classification while reading
``GITHUB_HOST``/``GITLAB_HOST``/``APM_GITLAB_HOSTS`` from the ambient
environment -- and ``ghe.corp.example`` is the exact value the docs tell
users to export. Verified it fails under ``GITHUB_HOST=ghe.corp.example``
before the fix and passes after; the vars are now cleared for that
invocation only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[FEATURE] apm auth: report and repair a host's credential before something fails

2 participants