From 9886f6e53b3e581e3bbaceec7b6e8f04b2e26d80 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 9 Sep 2026 16:49:23 -0700 Subject: [PATCH] feat(plugins): install plugins from private git repos using the user's stored credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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./.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. --- hermes_cli/git_credentials.py | 125 ++++++++++++++++++ hermes_cli/mcp_catalog.py | 3 +- hermes_cli/plugins_cmd.py | 37 ++++-- hermes_cli/profile_distribution.py | 3 +- tests/hermes_cli/test_mcp_catalog.py | 2 + .../test_plugin_private_repo_auth.py | 66 +++++++++ tests/hermes_cli/test_plugins_cmd.py | 12 +- website/docs/user-guide/features/plugins.md | 17 +++ 8 files changed, 247 insertions(+), 18 deletions(-) create mode 100644 hermes_cli/git_credentials.py create mode 100644 tests/hermes_cli/test_plugin_private_repo_auth.py diff --git a/hermes_cli/git_credentials.py b/hermes_cli/git_credentials.py new file mode 100644 index 0000000000..d08a5f0dec --- /dev/null +++ b/hermes_cli/git_credentials.py @@ -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./.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./.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 diff --git a/hermes_cli/mcp_catalog.py b/hermes_cli/mcp_catalog.py index 91e0058de6..321a4d67f7 100644 --- a/hermes_cli/mcp_catalog.py +++ b/hermes_cli/mcp_catalog.py @@ -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 diff --git a/hermes_cli/plugins_cmd.py b/hermes_cli/plugins_cmd.py index 532b55cb42..95f781cbe8 100644 --- a/hermes_cli/plugins_cmd.py +++ b/hermes_cli/plugins_cmd.py @@ -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: diff --git a/hermes_cli/profile_distribution.py b/hermes_cli/profile_distribution.py index 82f7fa3fc2..2734d2a822 100644 --- a/hermes_cli/profile_distribution.py +++ b/hermes_cli/profile_distribution.py @@ -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 diff --git a/tests/hermes_cli/test_mcp_catalog.py b/tests/hermes_cli/test_mcp_catalog.py index f4123c1041..3367974e19 100644 --- a/tests/hermes_cli/test_mcp_catalog.py +++ b/tests/hermes_cli/test_mcp_catalog.py @@ -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") diff --git a/tests/hermes_cli/test_plugin_private_repo_auth.py b/tests/hermes_cli/test_plugin_private_repo_auth.py new file mode 100644 index 0000000000..a40bb3d09f --- /dev/null +++ b/tests/hermes_cli/test_plugin_private_repo_auth.py @@ -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 diff --git a/tests/hermes_cli/test_plugins_cmd.py b/tests/hermes_cli/test_plugins_cmd.py index 4002698b0e..8c1a303d92 100644 --- a/tests/hermes_cli/test_plugins_cmd.py +++ b/tests/hermes_cli/test_plugins_cmd.py @@ -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") diff --git a/website/docs/user-guide/features/plugins.md b/website/docs/user-guide/features/plugins.md index c208f76bb7..c9df95287f 100644 --- a/website/docs/user-guide/features/plugins.md +++ b/website/docs/user-guide/features/plugins.md @@ -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: