feat(plugins): install plugins from private git repos using the user's stored credentials

`hermes plugins install owner/private-repo` failed for every private repository
even when the same user could `git clone` it from their shell: the hardened
`noninteractive_git_env` disables credential helpers, askpass and global config
(so a hostile repo can't make our plumbing prompt or hang), which also blocks
the user's own stored credential. The result was "could not read Username" or a
60s hang on a GUI askpass, with no hint about how to authenticate.

New `hermes_cli/git_credentials.py` resolves a credential up front from sources
the user already owns — GITHUB_TOKEN/GH_TOKEN (profile-scoped), `gh auth token`,
then `git credential fill` against their configured helpers with prompting
disabled (any host) — and hands it to git as a one-shot
`http.<origin>/.extraheader` via the GIT_CONFIG_* env block. Nothing lands in
the URL, `.git/config` or install metadata. The same path covers
`plugins update`, catalog MCP git installs and profile-distribution staging,
which share the same hardened env and the same failure.

A private-repo clone with no credential now fails fast with an actionable hint.
This commit is contained in:
Teknium
2026-09-09 16:49:23 -07:00
parent 6e07eb4838
commit 9886f6e53b
8 changed files with 247 additions and 18 deletions

View File

@@ -0,0 +1,125 @@
"""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 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:
result = subprocess.run(
[gh, "auth", "token"], capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=10, stdin=subprocess.DEVNULL, 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

View File

@@ -379,7 +379,8 @@ def _do_git_install(entry: CatalogEntry) -> Path:
# upfront so the fast path doesn't always fail noisily before the full-clone fallback.
is_sha_ref = bool(re.fullmatch(r"[0-9a-f]{7,40}", install.ref))
# Never hang on a credential prompt: installs run from CLI/dashboard flows nobody can answer.
_git_env = noninteractive_git_env()
from hermes_cli.git_credentials import with_git_auth
_git_env = with_git_auth(noninteractive_git_env(), install.url)
def _git(*args: str) -> int:
return subprocess.run([git, *args], stdin=subprocess.DEVNULL, env=_git_env).returncode

View File

@@ -463,10 +463,11 @@ def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -
def _git_or_raise(
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = ""
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "",
auth_url: str = "",
) -> subprocess.CompletedProcess:
"""Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error)."""
result = _run_plugin_git(git_exe, repo, *args, timeout=timeout)
result = _run_plugin_git(git_exe, repo, *args, timeout=timeout, auth_url=auth_url)
if result.returncode != 0:
raise PluginOperationError(failure_prefix + _safe_git_error(result, source_url))
return result
@@ -479,14 +480,15 @@ def _git_head_revision(repo: Path, git_exe: str) -> str:
).stdout.strip().lower()
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str) -> None:
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None:
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
for verb, args, failure_prefix in (
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
):
try:
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix)
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url,
auth_url=source_url if verb == "fetch" else "")
except subprocess.TimeoutExpired as exc:
raise PluginOperationError(f"Git {verb} of commit '{revision}' timed out after 60 seconds.") from exc
actual = _git_head_revision(repo, git_exe)
@@ -548,16 +550,21 @@ def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str]) -
raise PluginOperationError("git is not installed or not in PATH.")
clone_args = ["clone", "--depth", "1", *(["--no-checkout"] if revision else []), git_url, str(tmp_clone)]
try:
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args)
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url)
except FileNotFoundError as e:
raise PluginOperationError("git is not installed or not in PATH.") from e
except subprocess.TimeoutExpired as e:
raise PluginOperationError("Git clone timed out after 60 seconds.") from e
if result.returncode != 0:
raise PluginOperationError(f"Git clone failed:\n{_safe_git_error(result, git_url)}")
error = _safe_git_error(result, git_url)
if re.search(r"could not read Username|Authentication failed|Repository not found", error):
error += (
"\n\nIf this repository is private, authenticate first: run `gh auth login`, set GITHUB_TOKEN "
"(or GH_TOKEN) in your .env, or store a credential in git's credential helper for this host.")
raise PluginOperationError(f"Git clone failed:\n{error}")
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
if revision:
_checkout_exact_revision(tmp_clone, git_exe, revision)
_checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url)
return _git_head_revision(tmp_clone, git_exe)
@@ -1852,11 +1859,18 @@ def _clear_plugin_bytecode(target: Path) -> int:
return removed
def _run_plugin_git(git_exe: str, target: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
"""Run one git command inside a plugin checkout (non-interactive)."""
def _run_plugin_git(
git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "",
) -> subprocess.CompletedProcess:
"""Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote
a network verb talks to so a stored user credential for its host is attached (private repos)."""
env = noninteractive_git_env()
if auth_url:
from hermes_cli.git_credentials import with_git_auth
env = with_git_auth(env, auth_url)
return subprocess.run(
[git_exe, *args], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout,
cwd=str(target), stdin=subprocess.DEVNULL, env=noninteractive_git_env())
cwd=str(target), stdin=subprocess.DEVNULL, env=env)
def _stash_ref(git_exe: str, target: Path) -> str:
@@ -1916,7 +1930,8 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
stash_created, err = _autostash_dirty_tree(git_exe, target)
if err:
return False, err
result = _run_plugin_git(git_exe, target, "pull", "--ff-only")
origin = _run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15)
result = _run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip())
if result.returncode != 0:
err = _safe_git_error(result) or "git pull failed."
if not stash_created:

View File

@@ -230,10 +230,11 @@ def _looks_like_git_url(s: str) -> bool:
def _git_clone(url: str, dest: Path) -> None:
if _GITHUB_SHORTHAND_RE.match(url):
url = f"https://{url.rstrip('/')}"
from hermes_cli.git_credentials import with_git_auth
try:
subprocess.run(
["git", "clone", "--depth", "1", url, str(dest)], check=True, capture_output=True,
stdin=subprocess.DEVNULL, env=noninteractive_git_env(),
stdin=subprocess.DEVNULL, env=with_git_auth(noninteractive_git_env(), url),
)
except FileNotFoundError as exc:
raise DistributionError("git is required for git-URL installs") from exc

View File

@@ -747,6 +747,8 @@ class TestGitInstallShaRef:
monkeypatch.setattr(mcp_catalog.subprocess, "run", fake_run)
monkeypatch.setattr(mcp_catalog.shutil, "which", lambda x: "/usr/bin/git")
from hermes_cli import git_credentials
monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: None)
from hermes_cli.mcp_catalog import get_entry
entry = get_entry("demo")

View File

@@ -0,0 +1,66 @@
"""Private-repo plugin installs attach the user's stored HTTPS credential to git without persisting it."""
import base64
import subprocess
import sys
import pytest
from hermes_cli import git_credentials, plugins_cmd
from hermes_cli._subprocess_compat import noninteractive_git_env
def test_private_clone_authenticates_without_writing_credentials_to_checkout(tmp_path, monkeypatch):
"""A clone from a remote that rejects anonymous access succeeds when a credential is resolvable,
and the installed checkout carries no trace of it."""
upstream = tmp_path / "upstream.git"
subprocess.run(["git", "init", "-q", "--bare", str(upstream)], check=True)
work = tmp_path / "work"
subprocess.run(["git", "clone", "-q", str(upstream), str(work)], check=True)
(work / "plugin.yaml").write_text("name: probe\ndescription: d\nversion: '1'\n")
subprocess.run(["git", "-C", str(work), "-c", "user.name=t", "-c", "user.email=t@t", "add", "."], check=True)
subprocess.run(["git", "-C", str(work), "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "i"], check=True)
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", "HEAD"], check=True)
# Stand-in for a private remote: git is real, the URL is https, and the test asserts the auth
# header reached git by having git echo its effective config for that origin.
seen = {}
real_run = subprocess.run
def spy_run(argv, *a, **kw):
env = kw.get("env") or {}
if "clone" in argv:
headers = [env[f"GIT_CONFIG_VALUE_{i}"] for i in range(int(env["GIT_CONFIG_COUNT"]))
if env[f"GIT_CONFIG_KEY_{i}"] == "http.https://git.example.test/.extraheader"]
seen["headers"] = headers
argv = [a_ if a_ != "https://git.example.test/acme/probe.git" else str(upstream) for a_ in argv]
return real_run(argv, *a, **kw)
monkeypatch.setattr(plugins_cmd.subprocess, "run", spy_run)
monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: ("alice", "s3cret"))
dest = tmp_path / "clone"
plugins_cmd._clone_plugin_repo(dest, "https://git.example.test/acme/probe.git", None)
expected = base64.b64encode(b"alice:s3cret").decode()
assert seen["headers"] == [f"Authorization: basic {expected}"]
assert "s3cret" not in (dest / ".git" / "config").read_text()
assert expected not in (dest / ".git" / "config").read_text()
# Non-HTTPS URLs get no header; the hardened base env is otherwise untouched.
base = noninteractive_git_env()
assert git_credentials.with_git_auth(base, "git@github.com:acme/probe.git") == dict(base)
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell stub credential helper")
def test_credential_fill_uses_stored_helper_and_never_prompts(tmp_path, monkeypatch):
helper = tmp_path / "helper.sh"
helper.write_text("#!/bin/sh\n[ \"$1\" = get ] && printf 'username=bob\\npassword=pw-from-helper\\n'\n")
helper.chmod(0o755)
gitconfig = tmp_path / "gitconfig"
gitconfig.write_text(f'[credential "https://git.example.test"]\n\thelper = !{helper}\n')
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(gitconfig))
monkeypatch.setenv("GIT_ASKPASS", "/nonexistent/askpass-must-not-run")
assert git_credentials.resolve_git_basic_auth("https://git.example.test/acme/x.git") == ("bob", "pw-from-helper")
# Unknown host: no helper answers → None quickly, no prompt attempt escaped.
assert git_credentials.resolve_git_basic_auth("https://nothing.example.test/x.git") is None

View File

@@ -153,18 +153,18 @@ class TestResolveGitExecutable:
return_value="/resolved/git",
):
with patch.object(pc.subprocess, "run") as run:
# First call is `git status --porcelain` (clean tree),
# second is the pull itself.
# `git status --porcelain` (clean tree), `remote get-url origin`, then the pull.
run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""),
MagicMock(returncode=0, stdout="git@example.com:x.git\n", stderr=""),
MagicMock(returncode=0, stdout="Already up to date\n", stderr=""),
]
ok, msg = pc._git_pull_plugin_dir(tmp_path)
assert ok is True
assert run.call_count == 2
assert run.call_count == 3
for call in run.call_args_list:
assert call.args[0][0] == "/resolved/git"
assert run.call_args_list[1].args[0][1:] == ["pull", "--ff-only"]
assert run.call_args_list[2].args[0][1:] == ["pull", "--ff-only"]
def test_git_pull_clean_tree_never_stashes(self, tmp_path):
import hermes_cli.plugins_cmd as pc
@@ -174,6 +174,7 @@ class TestResolveGitExecutable:
with patch.object(pc.subprocess, "run") as run:
run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # status
MagicMock(returncode=0, stdout="git@example.com:x.git\n", stderr=""), # remote get-url
MagicMock(returncode=0, stdout="Updated\n", stderr=""), # pull
]
ok, msg = pc._git_pull_plugin_dir(tmp_path)
@@ -414,12 +415,13 @@ class TestCmdUpdate:
mock_run.side_effect = [
MagicMock(returncode=0, stdout="", stderr=""), # status: clean
MagicMock(returncode=0, stdout="git@example.com:x.git", stderr=""), # remote get-url
MagicMock(returncode=0, stdout="Updated", stderr=""), # pull
]
cmd_update("test-plugin")
assert mock_run.call_count == 2
assert mock_run.call_count == 3
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
@patch("hermes_cli.plugins_cmd._plugins_dir")

View File

@@ -191,6 +191,23 @@ plugin; choose a new exact commit explicitly with
profile-local install metadata contains no config values, environment values,
secrets, or capability grants.
### Installing from a private repository
`hermes plugins install` clones non-interactively (it never prompts for a
username or password), so a private repo needs a credential Hermes can find on
its own. For an `https://` source it tries, in order:
1. `GITHUB_TOKEN` or `GH_TOKEN` from your `.env` (GitHub hosts only).
2. The `gh` CLI's login (`gh auth login`), GitHub hosts only.
3. Your git credential helper (`git credential fill`) for that host — works for
GitLab, Bitbucket and self-hosted servers if a credential is already stored.
The credential is sent as a one-shot HTTP header for that install or update;
it is never written into the plugin's `.git/config` or the install metadata.
SSH sources (`git@host:owner/repo.git`) authenticate through your ssh-agent as
before. The same resolution applies to `hermes plugins update`, catalog MCP
installs from git, and profile distributions fetched from a git URL.
### What the allow-list does NOT gate
Several categories of plugin bypass `plugins.enabled` — they're part of Hermes' built-in surface and would break basic functionality if gated off by default: