Files
hermes-agent/agent/credential_sources.py
teknium1 6c7f693473 feat(auth): opt out of borrowing Codex CLI / Claude Code logins (auth.adopt_external_logins)
Hermes adopts and refreshes the Codex CLI (~/.codex/auth.json) and Claude Code
(~/.claude/.credentials.json) logins automatically whenever its own login is
missing or its refresh is rejected. Both providers hand out single-use, rotating
refresh tokens, so after an adoption two programs hold one token family and
whichever refreshes first logs the other out (#113023). #113816 made the dead
logins visible; this adds the switch the reporter asked for.

- `auth.adopt_external_logins` (config.yaml, default true — nothing changes for
  current users). When false:
  - `read_claude_code_credentials()` — the only reader of the borrowed Claude
    Code login — returns None, so the resolver fallback, the expired-token
    refresh, the pool seed/sync and the auxiliary 401 refresher never touch the
    file; the pool prunes a `claude_code` row an earlier adopting process
    persisted.
  - `_recover_codex_tokens_from_cli` returns None for both automatic recovery
    paths (rejected refresh, half-empty singleton); the real AuthError is
    surfaced instead. The interactive import offer in `hermes auth add
    openai-codex` still asks first and is unaffected.
  - One INFO line per process the first time adoption would have happened;
    `hermes auth list` / `hermes auth status anthropic|openai-codex` print the
    same line so the missing borrowed row is explained.
- Docs: security.md "Borrowed CLI logins" section; providers.md cross-links.

Live (temp HERMES_HOME + CLAUDE_CONFIG_DIR + CODEX_HOME, loopback logging token
endpoint): main with the key set to false still POSTed the Claude Code refresh,
rewrote the file's refresh token, seeded a claude_code pool row and adopted the
Codex CLI pair; on this branch the false arm makes zero Anthropic refresh POSTs,
leaves both external files byte-identical, seeds no row and prints the notice,
while the default arm is byte-for-byte today's behaviour.
2026-09-18 20:55:24 -07:00

306 lines
12 KiB
Python

"""Unified removal contract for every credential source Hermes reads from.
Readers live in ``agent.credential_pool``; what is unified here is **removal**:
``hermes auth remove <provider> <N>`` must make the entry stay gone across
``load_pool()`` calls. Each source registers a ``RemovalStep`` whose
``remove_fn`` cleans the external state the source reads from, and the
dispatcher suppresses ``(provider, source_id)`` in auth.json so the seeding
branch skips the upsert. Adding a source: wire a reader branch in
``_seed_from_*``, gate it behind ``is_source_suppressed``, register a step here.
Also home to the one policy switch over *borrowed* CLI logins (Codex CLI's
``~/.codex/auth.json``, Claude Code's ``~/.claude/.credentials.json``):
``auth.adopt_external_logins`` in config.yaml.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
from typing import Callable, List, Optional
logger = logging.getLogger(__name__)
EXTERNAL_LOGINS_NOT_ADOPTED_NOTICE = (
"External CLI logins (Codex CLI, Claude Code) are not adopted: auth.adopt_external_logins is false. "
"Hermes uses only its own logins; run `hermes auth add <provider>` to add one."
)
_notice_logged = False
def adopt_external_logins_enabled() -> bool:
"""``auth.adopt_external_logins`` (default True).
Codex and Claude OAuth refresh tokens are single-use and rotate, so once Hermes borrows a CLI's
token pair the two programs hold one token family and whichever refreshes first logs the other
out. When the user opts out, Hermes never reads or refreshes those files and says so once per
process (INFO) the first time it would have."""
global _notice_logged
try:
from hermes_cli.config import load_config_readonly
auth_cfg = (load_config_readonly() or {}).get("auth")
except Exception:
return True
enabled = not isinstance(auth_cfg, dict) or bool(auth_cfg.get("adopt_external_logins", True))
if not enabled and not _notice_logged:
_notice_logged = True
logger.info(EXTERNAL_LOGINS_NOT_ADOPTED_NOTICE)
return enabled
@dataclass
class RemovalResult:
"""Outcome of removing a credential source.
``cleaned``: external state actually mutated (printed to the user).
``hints``: diagnostics about state left intact or that the user must clean
up. ``suppress``: call ``suppress_credential_source`` afterwards so
``load_pool`` skips the source; only ``manual`` entries legitimately use False.
"""
cleaned: List[str] = field(default_factory=list)
hints: List[str] = field(default_factory=list)
suppress: bool = True
@dataclass
class RemovalStep:
"""How to remove one credential source.
``provider`` ``"*"`` matches any provider. ``match_fn`` overrides literal
``source_id`` matching (prefix patterns like ``env:*`` / ``config:*``).
``remove_fn(provider, removed_entry) -> RemovalResult``.
"""
provider: str
source_id: str
remove_fn: Callable[..., RemovalResult]
match_fn: Optional[Callable[[str], bool]] = None
description: str = ""
def matches(self, provider: str, source: str) -> bool:
if self.provider != "*" and self.provider != provider:
return False
if self.match_fn is not None:
return self.match_fn(source)
return source == self.source_id
def find_removal_step(provider: str, source: str) -> Optional[RemovalStep]:
"""First matching RemovalStep, or None (``manual``: nothing external to clean)."""
return next((step for step in _REGISTRY if step.matches(provider, source)), None)
def _remove_env_source(provider: str, removed) -> RemovalResult:
"""env:<VAR> — clear from ~/.hermes/.env; hint when the shell exports it."""
from hermes_cli.config import get_env_path, remove_env_value
result = RemovalResult()
env_var = removed.source[len("env:"):]
if not env_var:
return result
# Detect shell vs .env BEFORE remove_env_value pops os.environ. Read the
# .env as utf-8-sig like hermes_cli/config.py: a BOM-sensitive read would
# misreport a Notepad-edited .env var as a shell export.
env_in_process = bool(os.getenv(env_var))
env_in_dotenv = False
try:
env_path = get_env_path()
if env_path.exists():
env_in_dotenv = any(
line.strip().startswith(f"{env_var}=")
for line in env_path.read_text(encoding="utf-8-sig", errors="replace").splitlines()
)
except OSError:
pass
if remove_env_value(env_var):
result.cleaned.append(f"Cleared {env_var} from .env")
if env_in_process and not env_in_dotenv:
result.hints.extend([
f"Note: {env_var} is still set in your shell environment "
f"(not in ~/.hermes/.env).",
" Unset it there (shell profile, systemd EnvironmentFile, "
"launchd plist, etc.) or it will keep being visible to Hermes.",
f" The pool entry is now suppressed — Hermes will ignore "
f"{env_var} until you run `hermes auth add {provider}`.",
])
else:
result.hints.append(
f"Suppressed env:{env_var} — it will not be re-seeded even "
f"if the variable is re-exported later."
)
return result
def _remove_hermes_pkce(provider: str, removed) -> RemovalResult:
"""~/.hermes/.anthropic_oauth.json is ours — delete it outright."""
from hermes_constants import get_hermes_home
result = RemovalResult()
oauth_file = get_hermes_home() / ".anthropic_oauth.json"
if oauth_file.exists():
try:
oauth_file.unlink()
result.cleaned.append("Cleared Hermes Anthropic OAuth credentials")
except OSError as exc:
result.hints.append(f"Could not delete {oauth_file}: {exc}")
return result
def _remove_auth_store_oauth(provider: str, removed) -> RemovalResult:
"""Clear auth.json ``providers.<provider>`` (nous, minimax-oauth, xai-oauth, openai-codex).
Suppression by the dispatcher is still required — otherwise
``_seed_from_singletons`` re-seeds from any path that rewrites the block.
"""
from hermes_cli.auth import _auth_store_lock, _load_auth_store, _save_auth_store
result = RemovalResult()
with _auth_store_lock():
auth_store = _load_auth_store()
providers_dict = auth_store.get("providers")
if isinstance(providers_dict, dict) and provider in providers_dict:
del providers_dict[provider]
_save_auth_store(auth_store)
result.cleaned.append(f"Cleared {provider} OAuth tokens from auth store")
return result
def _remove_xai_oauth_device_code(provider: str, removed) -> RemovalResult:
result = _remove_auth_store_oauth(provider, removed)
result.hints.append(
"Run `hermes model` → xAI Grok OAuth (SuperGrok / Premium+) to re-authenticate if needed."
)
return result
def _remove_codex_device_code(provider: str, removed) -> RemovalResult:
"""Codex tokens also live in ~/.codex/auth.json (Codex CLI's file, kept).
Suppress the canonical ``device_code`` key — not just ``removed.source`` —
so a ``manual:device_code`` removal still blocks the re-seed path.
"""
from hermes_cli.auth import suppress_credential_source
result = _remove_auth_store_oauth(provider, removed)
suppress_credential_source(provider, "device_code")
result.hints.extend([
"Suppressed openai-codex device_code source — it will not be re-seeded.",
"Note: Codex CLI credentials still live in ~/.codex/auth.json",
"Run `hermes auth add openai-codex` to re-enable if needed.",
])
return result
def _remove_copilot_gh(provider: str, removed) -> RemovalResult:
"""The same Copilot token is seeded as gh_cli AND env:<VAR> rows, so suppress
every variant or the duplicates resurrect the entry. gh CLI and shell state
are left untouched."""
from hermes_cli.auth import suppress_credential_source
suppress_credential_source(provider, "gh_cli")
for env_var in ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"):
suppress_credential_source(provider, f"env:{env_var}")
return RemovalResult(hints=[
"Suppressed all copilot token sources (gh_cli + env vars) — they will not be re-seeded.",
"Note: Your gh CLI / shell environment is unchanged.",
"Run `hermes auth add copilot` to re-enable if needed.",
])
def _suppress_only(*hints: str) -> Callable[..., RemovalResult]:
"""remove_fn for sources whose backing file/config belongs to another tool
(Claude Code, Qwen CLI, config.yaml): never delete it, just suppress and
explain. ``{source}`` in a hint is the removed entry's source string."""
def remove_fn(provider: str, removed) -> RemovalResult:
return RemovalResult(hints=[h.format(source=removed.source) for h in hints])
return remove_fn
# ORDER MATTERS — ``find_removal_step`` returns the first match. Provider-
# specific steps precede the generic ``env:*`` step so copilot's ``env:GH_TOKEN``
# takes the copilot path (no .env edits) rather than the generic env-var removal.
_REGISTRY: List[RemovalStep] = [
RemovalStep(
provider="copilot", source_id="gh_cli",
match_fn=lambda src: src == "gh_cli" or src.startswith("env:"),
remove_fn=_remove_copilot_gh,
description="gh auth token / COPILOT_GITHUB_TOKEN / GH_TOKEN",
),
RemovalStep(
provider="*", source_id="env:",
match_fn=lambda src: src.startswith("env:"),
remove_fn=_remove_env_source,
description="Any env-seeded credential (XAI_API_KEY, DEEPSEEK_API_KEY, etc.)",
),
RemovalStep(
provider="anthropic", source_id="claude_code",
remove_fn=_suppress_only(
"Suppressed claude_code credential — it will not be re-seeded.",
"Note: Claude Code credentials still live in ~/.claude/.credentials.json",
"Run `hermes auth add anthropic` to re-enable if needed.",
),
description="~/.claude/.credentials.json",
),
RemovalStep(
provider="anthropic", source_id="hermes_pkce",
remove_fn=_remove_hermes_pkce,
description="~/.hermes/.anthropic_oauth.json",
),
RemovalStep(
provider="nous", source_id="device_code",
remove_fn=_remove_auth_store_oauth,
description="auth.json providers.nous",
),
RemovalStep(
provider="openai-codex", source_id="device_code",
match_fn=lambda src: src == "device_code" or src.endswith(":device_code"),
remove_fn=_remove_codex_device_code,
description="auth.json providers.openai-codex + ~/.codex/auth.json",
),
RemovalStep(
provider="xai-oauth", source_id="device_code",
remove_fn=_remove_xai_oauth_device_code,
description="auth.json providers.xai-oauth",
),
RemovalStep(
provider="qwen-oauth", source_id="qwen-cli",
remove_fn=_suppress_only(
"Suppressed qwen-cli credential — it will not be re-seeded.",
"Note: Qwen CLI credentials still live in ~/.qwen/oauth_creds.json",
"Run `hermes auth add qwen-oauth` to re-enable if needed.",
),
description="~/.qwen/oauth_creds.json",
),
RemovalStep(
provider="minimax-oauth", source_id="oauth",
remove_fn=_remove_auth_store_oauth,
description="auth.json providers.minimax-oauth",
),
RemovalStep(
provider="*", source_id="config:",
match_fn=lambda src: src.startswith("config:") or src == "model_config",
remove_fn=_suppress_only(
"Suppressed {source} — it will not be re-seeded.",
"Note: The underlying value in config.yaml is unchanged. Edit it "
"directly if you want to remove the credential from disk.",
),
description="Custom provider config.yaml api_key field",
),
]
# ---- BEGIN PLUGIN-COMPAT (revert-scheduled; see COMPAT_MANIFEST.md) ----
# Names external plugins imported from this module before the Sep 2026 decomposition.
# Internal code MUST NOT use these (scripts/check_compat_pointers.py fails CI if it does).
# The whole block is removed by reverting the commit that added it.
def register(step: RemovalStep) -> RemovalStep:
_REGISTRY.append(step)
return step
# ---- END PLUGIN-COMPAT ----