Files
hermes-agent/hermes_cli/secrets_cli.py

656 lines
24 KiB
Python

"""CLI handlers for ``hermes secrets bitwarden ...``."""
from __future__ import annotations
import argparse
import io
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
# NOTE: the Bitwarden backend (``agent.secret_sources.bitwarden``) pulls in
# ``cryptography`` at module-import time. On Windows the resulting
# ``cryptography._rust.pyd`` is mapped into the running process — and when
# that process is ``hermes update``, the self-lock preflight detects the
# loaded native module and defers (#86781). Keep the backend import lazy:
# this module is registered parse-time from ``hermes_cli.main`` and must not
# touch ``bw`` until a handler actually runs.
#
# ``_BWS_VERSION`` is duplicated here (as a plain string) so ``register_cli``
# can render the ``install --help`` text without importing the backend.
# ``agent.secret_sources.bitwarden._BWS_VERSION`` is the source of truth;
# bump both together when pinning a new bws release.
_BWS_VERSION = "2.0.0"
from hermes_cli._secrets_common import (
arg,
cfg_str,
cli_version,
disable_secret_source,
flag,
print_status_panel,
print_table,
prompt_index,
register_subcommands,
require_enabled,
rotate_token,
secret_cli_env,
section_cfg,
yn,
)
from hermes_cli.config import (
get_env_path,
load_config,
save_config,
save_env_value,
)
from hermes_cli.secret_prompt import masked_secret_prompt
# Old names kept bound: tests monkeypatch ``secrets_cli._bws_version``.
_bws_version = cli_version
_yn = yn
_NOT_BSM_TOKEN_WARNING = (
"[yellow]Warning: token doesn't start with '0.' — usually that means "
"you pasted something other than a BSM access token.[/yellow]"
)
_NOT_BSM_TOKEN_WARNING_CONTINUING = (
" [yellow]Warning: token doesn't start with '0.' — usually that means "
"you pasted something other than a BSM access token. Continuing anyway.[/yellow]"
)
def _load_bw():
"""Import ``agent.secret_sources.bitwarden`` on first use (crypto payload)."""
from agent.secret_sources import bitwarden as _bw
return _bw
def __getattr__(name: str):
"""PEP 562 module-level lazy resolver.
Existing callers (and upstream tests) monkeypatch attributes on ``hermes_cli.secrets_cli.bw``
directly. Resolving that attribute at module-import time would re-import ``cryptography``
eagerly — the very self-lock we are preventing (#86781).
"""
if name == "bw":
return _load_bw()
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
# ---------------------------------------------------------------------------
# Argparse wiring — called from hermes_cli.main
# ---------------------------------------------------------------------------
def register_cli(parent_parser: argparse.ArgumentParser) -> None:
"""Attach the ``bitwarden`` subcommand tree to a parent parser."""
register_subcommands(parent_parser, "secrets_bw_command", (
("setup", "Interactive wizard: install bws, store access token, pick project", cmd_setup, (
arg("--project-id", "Pre-select a project UUID instead of prompting"),
arg("--access-token", "Provide the access token non-interactively (will be stored in .env)"),
arg("--server-url", (
"Bitwarden region / self-hosted endpoint. Examples: "
"https://vault.bitwarden.com (US, default), "
"https://vault.bitwarden.eu (EU), or your self-hosted URL. "
"Skips the interactive region prompt."
)),
)),
("status", "Show config + binary + token validation status", cmd_status, ()),
("token", "Rotate the access token: validate a new one and store it in .env", cmd_token, (
arg("--access-token", "Provide the new token non-interactively (default: masked prompt)"),
flag("--no-verify", "Store without probing Bitwarden first (not recommended)"),
)),
("sync", "Fetch secrets now and report what changed", cmd_sync, (
flag("--apply", "Actually export the secrets into the current shell's env (default: dry-run)"),
)),
("disable", "Turn off the Bitwarden integration", cmd_disable, ()),
("install", f"Download and verify the pinned bws binary (v{_BWS_VERSION})", cmd_install, (
flag("--force", "Re-download even if a managed copy already exists"),
)),
))
# ---------------------------------------------------------------------------
# Handlers
# ---------------------------------------------------------------------------
def cmd_setup(args: argparse.Namespace) -> int:
bw = _load_bw()
console = Console()
console.print(
Panel.fit(
"[bold]Bitwarden Secrets Manager setup[/bold]\n\n"
"Need an access token? In the Bitwarden web app:\n"
" Secrets Manager → Machine accounts → [your account] →\n"
" Access tokens → Create access token\n\n"
"Copy the token (starts with [cyan]0.[/cyan]…) — it cannot be retrieved later.",
border_style="cyan",
)
)
# ------------------------------------------------------------------ binary
console.print()
console.print("[bold]Step 1[/bold] Install the bws CLI")
try:
binary = bw.find_bws(install_if_missing=False)
if binary is None:
console.print(" No bws on PATH — downloading…")
binary = bw.install_bws()
version = _bws_version(binary)
console.print(f" [green]✓[/green] {binary} ({version})")
except Exception as exc: # noqa: BLE001
console.print(f" [red]✗ Could not install bws: {exc}[/red]")
console.print(
" Manual install: "
"https://github.com/bitwarden/sdk-sm/releases"
)
return 1
# -- non-interactive guard --
if not sys.stdin.isatty():
# BWS_SERVER_URL env var is accepted as a non-interactive substitute for --server-url.
provided = {
"--access-token": args.access_token,
"--server-url": (args.server_url or "").strip() or os.environ.get("BWS_SERVER_URL", ""),
"--project-id": args.project_id,
}
missing = [flag for flag, value in provided.items() if not (value and value.strip())]
if missing:
console.print(
f" [red]Non-interactive mode (no TTY) requires all setup flags.[/red]\n"
f" Missing: {', '.join(missing)}\n\n"
" Usage:\n"
" hermes secrets bitwarden setup \\\n"
" --access-token '0.xxx' \\\n"
" --server-url 'https://vault.bitwarden.com' \\\n"
" --project-id 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx'"
)
return 1
# ------------------------------------------------------------------- token
console.print()
console.print("[bold]Step 2[/bold] Provide your access token")
cfg = load_config()
secrets_cfg = cfg.setdefault("secrets", {}).setdefault("bitwarden", {})
token_env = secrets_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
token = (args.access_token or "").strip()
if not token:
token = masked_secret_prompt(f" Paste access token ({token_env}): ").strip()
if not token:
console.print(" [red]Empty token, aborting.[/red]")
return 1
if not token.startswith("0."):
console.print(_NOT_BSM_TOKEN_WARNING_CONTINUING)
save_env_value(token_env, token)
os.environ[token_env] = token # so the test fetch below sees it
console.print(f" [green]✓[/green] stored in {get_env_path()} as {token_env}")
# ------------------------------------------------------------------ region
console.print()
console.print("[bold]Step 3[/bold] Pick a Bitwarden region")
server_url = _resolve_server_url(args, secrets_cfg, console)
if server_url is None:
return 1
if server_url:
console.print(f" [green]✓[/green] using {server_url}")
else:
console.print(
" [green]✓[/green] using bws default "
"(US Cloud, https://vault.bitwarden.com)"
)
# ------------------------------------------------------------------- project
project_given = bool(args.project_id and args.project_id.strip())
if project_given:
project_id = args.project_id.strip()
else:
console.print()
console.print("[bold]Step 4[/bold] Pick a project")
projects = _list_projects(binary, token, console, server_url=server_url)
if projects is None:
return 1
if not projects:
console.print(" [yellow]No projects visible to this machine account.[/yellow]")
console.print(
" In the Bitwarden web app, open the machine account → Projects tab "
"and grant it access to at least one project."
)
return 1
print_table(console, (("#", {"style": "cyan", "width": 4}), "Name", ("ID", {"style": "dim"})),
((str(i), p.get("name", "?"), p.get("id", "?")) for i, p in enumerate(projects, 1)))
idx = prompt_index(console, f" Select project [1-{len(projects)}]: ", len(projects))
project_id = projects[idx - 1]["id"]
# ------------------------------------------------------------------- test
console.print()
console.print(f"[bold]Step {4 if project_given else 5}[/bold] Test fetch")
try:
secrets, warnings = bw.fetch_bitwarden_secrets(
access_token=token,
project_id=project_id,
binary=binary,
use_cache=False,
server_url=server_url,
)
except Exception as exc: # noqa: BLE001
console.print(f" [red]✗ Fetch failed: {exc}[/red]")
return 1
if not secrets:
console.print(" [yellow]Fetch succeeded but the project has no secrets.[/yellow]")
else:
print_table(console, (("Name", {"style": "cyan"}), "Status"),
((key, _fetch_status(key, token_env)) for key in sorted(secrets)))
for w in warnings:
console.print(f" [yellow]warning:[/yellow] {w}")
# ------------------------------------------------------------------- save
secrets_cfg["enabled"] = True
secrets_cfg["project_id"] = project_id
secrets_cfg["server_url"] = server_url
secrets_cfg.setdefault("access_token_env", token_env)
secrets_cfg.setdefault("cache_ttl_seconds", 300)
secrets_cfg.setdefault("override_existing", True)
secrets_cfg.setdefault("auto_install", True)
save_config(cfg)
console.print()
console.print(
"[green]✓ Bitwarden Secrets Manager is enabled.[/green] "
"Secrets will be pulled at the start of every Hermes process."
)
console.print(
" Status: [cyan]hermes secrets bitwarden status[/cyan]\n"
" Refresh: [cyan]hermes secrets bitwarden sync[/cyan]\n"
" Disable: [cyan]hermes secrets bitwarden disable[/cyan]"
)
return 0
def _bw_cfg(cfg: dict) -> dict:
return section_cfg(cfg, "bitwarden")
def _fetch_status(key: str, token_env: str) -> str:
if key == token_env:
return "[dim]bootstrap token — never overrides itself[/dim]"
if os.environ.get(key):
return "[yellow]already set in env (will be overwritten)[/yellow]"
return "[green]new[/green]"
def cmd_status(args: argparse.Namespace) -> int:
bw = _load_bw()
console = Console()
bw_cfg = _bw_cfg(load_config())
enabled = bool(bw_cfg.get("enabled"))
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
project_id = bw_cfg.get("project_id", "")
server_url = cfg_str(bw_cfg, "server_url")
token = os.environ.get(token_env, "").strip()
token_set = bool(token)
binary = bw.find_bws(install_if_missing=False)
token_validation, validation_messages = _token_validation_status(
enabled=enabled,
binary=binary,
token=token,
server_url=server_url,
)
print_status_panel(console, "Bitwarden Secrets Manager", (
("Enabled", _yn(enabled)),
("Token env var", token_env),
("Token in env", _yn(token_set)),
("Token validation", token_validation),
("Project ID", project_id or "[dim](unset)[/dim]"),
("Server URL", server_url or "[dim]default (US Cloud, https://vault.bitwarden.com)[/dim]"),
("Override existing", _yn(bool(bw_cfg.get("override_existing", False)))),
("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300))),
("Auto-install", _yn(bool(bw_cfg.get("auto_install", True)))),
("bws binary",
f"{binary} ({_bws_version(binary)})" if binary else "[yellow]not installed[/yellow]"),
))
for message in validation_messages:
console.print(message)
if not enabled:
console.print("\n Run [cyan]hermes secrets bitwarden setup[/cyan] to enable.")
return 0
if not token_set:
console.print(
f"\n [yellow]Enabled but {token_env} is not set — Hermes will skip BSM "
"and warn on next startup.[/yellow]"
)
if not project_id:
console.print(
"\n [yellow]Enabled but no project_id — nothing to fetch.[/yellow]"
)
return 0
def cmd_token(args: argparse.Namespace) -> int:
"""Rotate the BSM access token without re-running the whole setup wizard.
Prompts for (or accepts via ``--access-token``) a new machine-account token, probes Bitwarden
with it (unless ``--no-verify``), and only then persists it to .env — so a bad paste never
bricks the working token.
"""
bw = _load_bw()
console = Console()
bw_cfg = _bw_cfg(load_config())
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
server_url = cfg_str(bw_cfg, "server_url")
def verify(token: str) -> bool:
if not token.startswith("0."):
console.print(_NOT_BSM_TOKEN_WARNING)
if args.no_verify:
return True
binary = bw.find_bws(install_if_missing=True)
if binary is None:
console.print(
"[red]bws binary not available — cannot verify. "
"Re-run with --no-verify to store anyway.[/red]"
)
return False
console.print("Verifying against Bitwarden…")
projects = _list_projects(binary, token, console, server_url=server_url)
if projects is None:
console.print(
"[red]✗ New token was rejected — nothing was changed.[/red]"
)
return False
console.print(
f"[green]✓ Token accepted[/green] "
f"({len(projects)} project{'s' if len(projects) != 1 else ''} visible)."
)
project_id = str(bw_cfg.get("project_id", "") or "")
if project_id and projects and project_id not in {p["id"] for p in projects}:
console.print(
f"[yellow]Warning: configured project {project_id} is not visible "
"to this machine account. Grant it access in the Bitwarden web "
"app or re-run `hermes secrets bitwarden setup` to pick a "
"different project.[/yellow]"
)
return True
return rotate_token(
console, args.access_token, token_env,
flag="--access-token",
intro=(
"Create a new token in the Bitwarden web app:\n"
" Secrets Manager → Machine accounts → [your account] → "
"Access tokens → Create access token\n"
),
prompt=f"Paste new access token ({token_env}): ",
verify=verify,
save=save_env_value, env_path=get_env_path, clear_caches=bw.clear_caches,
disabled_note=None if bw_cfg.get("enabled") else (
"[yellow]Note: the Bitwarden integration is currently disabled — "
"run `hermes secrets bitwarden setup` (or set "
"secrets.bitwarden.enabled: true) to turn it on.[/yellow]"
),
)
def cmd_sync(args: argparse.Namespace) -> int:
bw = _load_bw()
console = Console()
bw_cfg = _bw_cfg(load_config())
if not require_enabled(console, bw_cfg, "Bitwarden", "bitwarden"):
return 1
token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN")
token = os.environ.get(token_env, "").strip()
if not token:
console.print(f"[red]{token_env} is not set.[/red]")
return 1
project_id = bw_cfg.get("project_id", "")
if not project_id:
console.print("[red]No project_id configured.[/red]")
return 1
server_url = cfg_str(bw_cfg, "server_url")
try:
secrets, warnings = bw.fetch_bitwarden_secrets(
access_token=token,
project_id=project_id,
use_cache=False,
server_url=server_url,
)
except Exception as exc: # noqa: BLE001
console.print(f"[red]Fetch failed: {exc}[/red]")
return 1
if not secrets:
console.print("[yellow]No secrets in project.[/yellow]")
return 0
override = bool(bw_cfg.get("override_existing", False)) or args.apply
rows = []
applied = 0
for key in sorted(secrets):
if key == token_env:
rows.append((key, "[dim]skip (bootstrap token)[/dim]"))
continue
already = bool(os.environ.get(key))
if already and not override:
rows.append((key, "[dim]skip (already set)[/dim]"))
continue
if args.apply:
os.environ[key] = secrets[key]
applied += 1
rows.append((key, "[green]exported[/green]" + (" (overrode)" if already else "")))
else:
rows.append((key, "[green]would export[/green]" + (" (overrides)" if already else "")))
print_table(console, (("Name", {"style": "cyan"}), "Action"), rows, warnings)
if not args.apply:
console.print(
"\n This was a dry-run — secrets are picked up automatically on the "
"next [cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] "
"to export into the current shell instead."
)
else:
console.print(f"\n [green]Exported {applied} secret(s) into current process.[/green]")
return 0
def cmd_disable(args: argparse.Namespace) -> int:
return disable_secret_source(
"bitwarden",
"[green]Disabled.[/green] Bitwarden secrets will NOT be pulled on the next "
"Hermes invocation.\n"
" Your access token is left in .env — remove it manually if you also want "
"to revoke the credential.",
)
def cmd_install(args: argparse.Namespace) -> int:
bw = _load_bw()
console = Console()
try:
path = bw.install_bws(force=bool(args.force))
console.print(f"[green]✓[/green] {path} ({_bws_version(path)})")
return 0
except Exception as exc: # noqa: BLE001
console.print(f"[red]Install failed: {exc}[/red]")
return 1
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _token_validation_status(
*,
enabled: bool,
binary: Optional[Path],
token: str,
server_url: str = "",
) -> tuple[str, list[str]]:
if not enabled:
return "[dim]not checked[/dim] (integration disabled)", []
if not token:
return "[dim]not checked[/dim] (token missing)", []
if binary is None:
return "[dim]not checked[/dim] (bws not installed)", []
messages: list[str] = []
if not token.startswith("0."):
messages.append(_NOT_BSM_TOKEN_WARNING_CONTINUING)
capture = io.StringIO()
probe_console = Console(file=capture, record=True, width=200)
projects = _list_projects(binary, token, probe_console, server_url=server_url)
if projects is None:
details = probe_console.export_text(styles=False).strip()
if details:
messages.extend(line.rstrip() for line in details.splitlines())
return "[red]failed[/red]", messages
return "[green]passed[/green]", messages
def _list_projects(
binary: Path, token: str, console: Console, *, server_url: str = ""
) -> Optional[List[dict]]:
"""Call ``bws project list`` and return the parsed list, or None on failure."""
env = secret_cli_env()
env["BWS_ACCESS_TOKEN"] = token
if server_url:
env["BWS_SERVER_URL"] = server_url
try:
res = subprocess.run(
[str(binary), "project", "list", "--output", "json"],
env=env,
capture_output=True,
text=True, encoding='utf-8', errors='replace',
timeout=15,
)
except (OSError, subprocess.TimeoutExpired) as exc:
console.print(f" [red]Couldn't list projects: {exc}[/red]")
return None
if res.returncode != 0:
err = (res.stderr or res.stdout).strip()[:300]
console.print(f" [red]bws project list failed: {err}[/red]")
lowered = err.lower()
if "invalid_client" in lowered or "400 bad request" in lowered:
console.print(
" [yellow]'invalid_client' from the US identity endpoint usually "
"means the token is for a different Bitwarden region. Re-run "
"[cyan]hermes secrets bitwarden setup[/cyan] and pick EU or "
"self-hosted at the region prompt, or set [cyan]secrets.bitwarden."
"server_url[/cyan] in config.yaml.[/yellow]"
)
elif "authorization" in lowered or "invalid" in lowered:
console.print(
" [yellow]This usually means the access token is wrong or revoked. "
"Double-check it in the Bitwarden web app.[/yellow]"
)
return None
try:
data = json.loads(res.stdout or "[]")
except json.JSONDecodeError as exc:
console.print(f" [red]bws returned non-JSON: {exc}[/red]")
return None
if not isinstance(data, list):
return []
return [p for p in data if isinstance(p, dict) and p.get("id")]
# Canonical Bitwarden region endpoints. Keep in sync with what Bitwarden
# publishes — these are stable but if a third region appears, add it here
# and to the prompt below.
_REGION_PRESETS = [
("US Cloud (https://vault.bitwarden.com — bws default)", ""),
("EU Cloud (https://vault.bitwarden.eu)", "https://vault.bitwarden.eu"),
]
def _resolve_server_url(
args: argparse.Namespace,
secrets_cfg: dict,
console: Console,
) -> Optional[str]:
"""Pick a Bitwarden server URL for setup.
Resolution order: 1. ``--server-url`` CLI flag (non-interactive) 2. ``BWS_SERVER_URL`` env var
(so users running with that already set in their shell don't have to re-enter it) 3. Existing
``secrets.bitwarden.server_url`` value (for re-runs) 4. Interactive menu: US / EU / self-hosted
"""
if args.server_url and args.server_url.strip():
return args.server_url.strip()
env_url = os.environ.get("BWS_SERVER_URL", "").strip()
if env_url:
console.print(
f" Detected [cyan]BWS_SERVER_URL[/cyan]={env_url} in your shell — using it."
)
return env_url
existing = cfg_str(secrets_cfg, "server_url")
if existing:
console.print(
f" Existing config: [cyan]{existing}[/cyan]. "
"Press Enter to keep, or pick a different option below."
)
table = Table(show_header=True, header_style="bold", box=None, padding=(0, 2))
table.add_column("#", style="cyan", width=4)
table.add_column("Region / endpoint")
for i, (label, _url) in enumerate(_REGION_PRESETS, 1):
table.add_row(str(i), label)
table.add_row(str(len(_REGION_PRESETS) + 1), "Self-hosted / custom URL")
console.print(table)
custom_idx = len(_REGION_PRESETS) + 1
prompt = f" Select region [1-{custom_idx}]"
if existing:
prompt += " (Enter to keep current)"
idx = prompt_index(
console,
prompt + ": ",
custom_idx,
allow_empty=bool(existing),
empty_message=" [red]Enter a number.[/red]",
)
if idx == 0:
return existing
if idx <= len(_REGION_PRESETS):
return _REGION_PRESETS[idx - 1][1]
custom = console.input(
" Enter your Bitwarden server URL "
"(e.g. https://vault.example.com): "
).strip()
if not custom:
console.print(" [red]Empty URL, aborting.[/red]")
return None
if not custom.startswith(("http://", "https://")):
console.print(
" [yellow]Warning: URL doesn't start with http:// or "
"https:// — bws may reject it.[/yellow]"
)
return custom