Files
hermes-agent/hermes_cli/git_credentials.py
Teknium d783c312a7 fix(plugins): run gh auth token under the noninteractive git env
The MCP-catalog noninteractive contract test asserts every subprocess spawned during a git
install carries GIT_TERMINAL_PROMPT=0 and a closed stdin; the gh token probe was spawned with
the inherited env. Use the same hardened env (plus GH_PROMPT_DISABLED) so gh cannot open a
browser/device flow either, and let the contract accept a stdin fed by input= (credential fill
writes its request and closes).
2026-09-10 01:00:19 -07:00

128 lines
5.1 KiB
Python

"""Non-interactive HTTPS credentials for Hermes's internal git clones (private plugin/MCP/profile repos).
:func:`noninteractive_git_env` deliberately disables credential helpers, askpass and global git
config so a hostile repo cannot make our plumbing prompt or hang. The cost is that a *private*
repo the user can already clone from their shell fails inside ``hermes plugins install`` with
"could not read Username" (or hangs on a GUI askpass until the timeout). This module resolves a
credential up front, from sources the user already owns, and passes it to git as a one-shot
``http.<origin>/.extraheader`` in the environment — never in the URL and never in ``.git/config``,
so nothing is persisted into the installed checkout.
Resolution order for an ``https://`` URL:
1. ``GITHUB_TOKEN`` / ``GH_TOKEN`` (profile-scoped, GitHub hosts only).
2. ``gh auth token`` (GitHub hosts only; the gh CLI's own login).
3. ``git credential fill`` against the user's configured credential helpers (any host: GitLab,
Bitbucket, self-hosted) with prompting disabled, so a stored credential is returned and a
missing one fails in ~100 ms instead of asking.
"""
from __future__ import annotations
import base64
import logging
import os
import shutil
import subprocess
import urllib.parse
from typing import Mapping, Optional
from hermes_cli._subprocess_compat import noninteractive_git_env, windows_hide_flags
logger = logging.getLogger(__name__)
_GITHUB_HOSTS = {"github.com", "gist.github.com"}
def _https_origin(url: str) -> Optional[str]:
parsed = urllib.parse.urlsplit(url)
if parsed.scheme != "https" or not parsed.hostname:
return None
host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname
if parsed.port is not None:
host = f"{host}:{parsed.port}"
return f"https://{host}"
def _github_token() -> Optional[str]:
from agent.secret_scope import get_secret
token = get_secret("GITHUB_TOKEN") or get_secret("GH_TOKEN")
if token:
return token
gh = shutil.which("gh")
if not gh:
return None
try:
env = noninteractive_git_env()
env["GH_PROMPT_DISABLED"] = "1"
result = subprocess.run(
[gh, "auth", "token"], capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=10, stdin=subprocess.DEVNULL, env=env, creationflags=windows_hide_flags())
except (OSError, subprocess.TimeoutExpired) as exc:
logger.debug("gh auth token lookup failed: %s", exc)
return None
if result.returncode != 0:
return None
return result.stdout.strip() or None
def _credential_fill(origin: str) -> Optional[tuple[str, str]]:
"""``(username, password)`` from the user's own git credential helpers, never prompting."""
git = shutil.which("git")
if not git:
return None
env = dict(os.environ)
env["GIT_TERMINAL_PROMPT"] = "0"
env["GCM_INTERACTIVE"] = "Never"
# A GUI askpass (VS Code, ssh-askpass) would block on a dialog nobody sees.
env.pop("GIT_ASKPASS", None)
env.pop("SSH_ASKPASS", None)
parsed = urllib.parse.urlsplit(origin)
request = f"protocol=https\nhost={parsed.netloc}\n\n"
try:
result = subprocess.run(
[git, "-c", "core.askPass=", "credential", "fill"], input=request, capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=15, env=env,
creationflags=windows_hide_flags())
except (OSError, subprocess.TimeoutExpired) as exc:
logger.debug("git credential fill failed for %s: %s", origin, exc)
return None
if result.returncode != 0:
return None
fields = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line)
if fields.get("password"):
return fields.get("username", ""), fields["password"]
return None
def resolve_git_basic_auth(url: str) -> Optional[tuple[str, str]]:
"""``(username, password)`` for *url*, or None for non-HTTPS URLs / no stored credential."""
origin = _https_origin(url)
if origin is None:
return None
if urllib.parse.urlsplit(origin).hostname in _GITHUB_HOSTS:
token = _github_token()
if token:
return "x-access-token", token
return _credential_fill(origin)
def with_git_auth(env: Mapping[str, str], url: str) -> dict[str, str]:
"""Copy of *env* (a :func:`noninteractive_git_env` result) that authenticates HTTPS requests to
*url*'s origin via a ``GIT_CONFIG_*`` ``http.<origin>/.extraheader`` entry when a credential is
available; unchanged otherwise. The header lives only in this process environment."""
env = dict(env)
origin = _https_origin(url)
if origin is None:
return env
auth = resolve_git_basic_auth(url)
if auth is None:
return env
encoded = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode()
idx = int(env.get("GIT_CONFIG_COUNT", "0") or 0)
env[f"GIT_CONFIG_KEY_{idx}"] = f"http.{origin}/.extraheader"
env[f"GIT_CONFIG_VALUE_{idx}"] = f"Authorization: basic {encoded}"
env["GIT_CONFIG_COUNT"] = str(idx + 1)
return env