"""CLI handlers for ``hermes secrets onepassword ...``. Unlike Bitwarden, the ``op`` binary is NOT auto-installed: 1Password publishes the CLI through OS package managers and signed installers, so Hermes expects an already-installed, already- authenticated ``op`` and never downloads one. """ from __future__ import annotations import argparse import os import subprocess from pathlib import Path from typing import Optional from rich.console import Console from rich.panel import Panel from agent.secret_sources import onepassword as op_src from hermes_cli._secrets_common import ( arg, cfg_str, cli_version, disable_secret_source, flag, print_status_panel, print_table, 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, ) _DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN" _DOCS_URL = "https://developer.1password.com/docs/cli/get-started/" # Old names kept bound: tests call ``onepassword_secrets_cli._op_version`` directly. _op_version = cli_version _yn = yn def _op_cfg(cfg: dict) -> dict: return section_cfg(cfg, "onepassword") def _references(op_cfg: dict) -> dict: env = op_cfg.get("env") return env if isinstance(env, dict) else {} # --------------------------------------------------------------------------- # Argparse wiring — called from hermes_cli.main # --------------------------------------------------------------------------- def register_cli(parent_parser: argparse.ArgumentParser) -> None: """Attach the ``onepassword`` subcommand tree to a parent parser.""" register_subcommands(parent_parser, "secrets_op_command", ( ("setup", "Verify the op CLI, set account / token env var, and enable", cmd_setup, ( arg("--account", "1Password account shorthand or sign-in address (op --account)"), arg("--token-env", f"Env var holding a service-account token (default {_DEFAULT_TOKEN_ENV})"), arg("--token", "Service-account token to store in .env non-interactively"), arg("--binary-path", "Absolute path to the op binary (skips PATH lookup)"), )), ("status", "Show config + op binary + references", cmd_status, ()), ("token", "Rotate the service-account token: validate and store it in .env", cmd_token, ( arg("--token", "Provide the new token non-interactively (default: masked prompt)"), flag("--no-verify", "Store without probing 1Password first (not recommended)"), )), ("set", "Map an env var to an op:// reference", cmd_set, ( arg("env_var", "Environment variable name, e.g. OPENAI_API_KEY"), arg("reference", "1Password reference, e.g. op://Private/OpenAI/api key"), )), ("remove", "Remove an env-var → reference mapping", cmd_remove, ( arg("env_var", "Environment variable name to unmap"), )), ("sync", "Resolve references now and report what changed", cmd_sync, ( flag("--apply", "Actually export resolved values into the current shell (default: dry-run)"), )), ("disable", "Turn off the 1Password integration", cmd_disable, ()), )) # --------------------------------------------------------------------------- # Handlers # --------------------------------------------------------------------------- def cmd_setup(args: argparse.Namespace) -> int: console = Console() console.print( Panel.fit( "[bold]1Password secret source setup[/bold]\n\n" "Hermes resolves [cyan]op://vault/item/field[/cyan] references through your\n" "already-installed, already-authenticated 1Password CLI (`op`).\n\n" f"Don't have it yet? Install + sign in: [cyan]{_DOCS_URL}[/cyan]", border_style="cyan", ) ) cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) # ------------------------------------------------------------------ binary console.print() console.print("[bold]Step 1[/bold] Locate the op CLI") binary_path = (args.binary_path or op_cfg.get("binary_path", "") or "").strip() binary = op_src.find_op(binary_path) if binary is None: console.print( f" [red]✗ {binary_path} is not an executable op binary.[/red]" if binary_path else " [red]✗ op not found on PATH.[/red]" ) console.print(f" Install the 1Password CLI: {_DOCS_URL}") return 1 console.print(f" [green]✓[/green] {binary} ({_op_version(binary)})") if binary_path: op_cfg["binary_path"] = binary_path # ----------------------------------------------------------------- account if args.account and args.account.strip(): op_cfg["account"] = args.account.strip() console.print(f" Account: [cyan]{op_cfg['account']}[/cyan]") # ------------------------------------------------------------------- token console.print() console.print("[bold]Step 2[/bold] Authentication") token_env = (args.token_env or op_cfg.get("service_account_token_env") or _DEFAULT_TOKEN_ENV).strip() op_cfg["service_account_token_env"] = token_env token = (args.token or "").strip() if token: save_env_value(token_env, token) os.environ[token_env] = token console.print(f" [green]✓[/green] service-account token stored in " f"{get_env_path()} as {token_env}") elif os.environ.get(token_env): console.print(f" [green]✓[/green] using service-account token from {token_env}") else: who = _op_whoami(binary, op_cfg.get("account", "")) if who: console.print(f" [green]✓[/green] using existing op session ({who})") else: console.print( " [yellow]No service-account token and no active op session " "detected.[/yellow]\n" " Either run [cyan]op signin[/cyan] (desktop/interactive) or set a " f"service-account token in {token_env}, then re-run status." ) # ----------------------------------------------------------------- enable op_cfg["enabled"] = True op_cfg.setdefault("env", {}) op_cfg.setdefault("cache_ttl_seconds", 300) op_cfg.setdefault("override_existing", True) save_config(cfg) console.print() console.print("[green]✓ 1Password secret source is enabled.[/green]") console.print( " Map credentials: [cyan]hermes secrets onepassword set OPENAI_API_KEY " "\"op://Private/OpenAI/api key\"[/cyan]\n" " Preview: [cyan]hermes secrets onepassword sync[/cyan]\n" " Status: [cyan]hermes secrets onepassword status[/cyan]" ) return 0 def cmd_status(args: argparse.Namespace) -> int: console = Console() op_cfg = _op_cfg(load_config()) enabled = bool(op_cfg.get("enabled")) account = cfg_str(op_cfg, "account") token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) binary_path = cfg_str(op_cfg, "binary_path") references = _references(op_cfg) token_set = bool(os.environ.get(token_env)) binary = op_src.find_op(binary_path) print_status_panel(console, "1Password secret source", ( ("Enabled", _yn(enabled)), ("Account", account or "[dim]default[/dim]"), ("Token env var", token_env), ("Token in env", _yn(token_set)), ("Override existing", _yn(bool(op_cfg.get("override_existing", True)))), ("Cache TTL (s)", str(op_cfg.get("cache_ttl_seconds", 300))), ("op binary", f"{binary} ({_op_version(binary)})" if binary else "[yellow]not found[/yellow]"), ("References", str(len(references))), )) if references: print_table(console, (("Env var", {"style": "cyan"}), "Reference"), ((name, str(references[name])) for name in sorted(references))) if not enabled: console.print("\n Run [cyan]hermes secrets onepassword setup[/cyan] to enable.") return 0 if binary and not token_set: who = _op_whoami(binary, account) if who: console.print(f"\n [green]Active op session:[/green] {who}") else: console.print( f"\n [yellow]No active op session and {token_env} is unset — " "Hermes will warn and skip 1Password on next startup.[/yellow]" ) if not references: console.print( "\n [yellow]No references mapped yet.[/yellow] Add one: " "[cyan]hermes secrets onepassword set ENV_VAR \"op://…\"[/cyan]" ) return 0 def cmd_set(args: argparse.Namespace) -> int: console = Console() # Reuse the backend validator so the CLI and startup paths agree on what a # valid reference is — and store the *validated/stripped* value, not the # raw arg (so trailing whitespace never lands in config.yaml). valid, warnings = op_src._validate_references({args.env_var: args.reference}) if args.env_var not in valid: for w in warnings: console.print(f"[red]{w}[/red]") return 1 cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) if not isinstance(op_cfg.get("env"), dict): op_cfg["env"] = {} op_cfg["env"][args.env_var] = valid[args.env_var] save_config(cfg) console.print( f"[green]✓[/green] mapped [cyan]{args.env_var}[/cyan] → " f"{valid[args.env_var]}" ) if not op_cfg.get("enabled"): console.print( " [yellow]Note: the integration is disabled — run " "[cyan]hermes secrets onepassword setup[/cyan] to turn it on.[/yellow]" ) return 0 def cmd_remove(args: argparse.Namespace) -> int: console = Console() cfg = load_config() op_cfg = cfg.setdefault("secrets", {}).setdefault("onepassword", {}) env_map = op_cfg.get("env") if not isinstance(env_map, dict) or args.env_var not in env_map: console.print(f"[yellow]{args.env_var} is not mapped.[/yellow]") return 1 del env_map[args.env_var] save_config(cfg) console.print(f"[green]✓[/green] removed mapping for [cyan]{args.env_var}[/cyan]") return 0 def cmd_token(args: argparse.Namespace) -> int: """Rotate the 1Password service-account token without the full setup flow. Prompts for (or accepts via ``--token``) a new service-account token, verifies it with ``op whoami`` (unless ``--no-verify``), and only then persists it to .env — so a bad paste never bricks the working token. """ console = Console() op_cfg = _op_cfg(load_config()) token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) account = cfg_str(op_cfg, "account") binary_path = cfg_str(op_cfg, "binary_path") def verify(token: str) -> bool: binary = op_src.find_op(binary_path) if binary is None: console.print( f"[red]op CLI not found — install it ({_DOCS_URL}) or " "re-run with --no-verify to store anyway.[/red]" ) return False console.print("Verifying with `op whoami`…") who = _op_whoami(binary, account, token_value=token) if who is None: console.print( "[red]✗ New token was rejected by op — nothing was changed.[/red]" ) return False console.print(f"[green]✓ Token accepted[/green] ({who}).") return True return rotate_token( console, args.token, token_env, flag="--token", intro=( "Create a new service-account token at " "https://my.1password.com → Developer → Service Accounts.\n" ), prompt=f"Paste new token ({token_env}): ", verify=None if args.no_verify else verify, save=save_env_value, env_path=get_env_path, clear_caches=op_src.clear_caches, disabled_note=None if op_cfg.get("enabled") else ( "[yellow]Note: the 1Password integration is currently disabled — " "run `hermes secrets onepassword setup` to turn it on.[/yellow]" ), ) def cmd_sync(args: argparse.Namespace) -> int: console = Console() op_cfg = _op_cfg(load_config()) if not require_enabled(console, op_cfg, "1Password", "onepassword"): return 1 references = _references(op_cfg) if not references: console.print( "[yellow]No op:// references configured. Add one with " "`hermes secrets onepassword set ENV_VAR \"op://…\"`.[/yellow]" ) return 0 account = cfg_str(op_cfg, "account") token_env = op_cfg.get("service_account_token_env", _DEFAULT_TOKEN_ENV) binary_path = cfg_str(op_cfg, "binary_path") # --apply delegates to the same code path startup uses, so the skip / # override / token-guard policy lives in exactly one place. if args.apply: result = op_src.apply_onepassword_secrets( enabled=True, env=references, account=account, service_account_token_env=token_env, binary_path=binary_path, override_existing=bool(op_cfg.get("override_existing", True)), cache_ttl_seconds=0, # an explicit sync always resolves fresh ) if result.error: console.print(f"[red]{result.error}[/red]") return 1 print_table( console, (("Env var", {"style": "cyan"}), "Action"), [(name, "[green]exported[/green]") for name in sorted(result.applied)] + [(name, "[dim]skipped (already set / token var)[/dim]") for name in sorted(result.skipped)], result.warnings, ) console.print( f"\n [green]Exported {len(result.applied)} secret(s) into current " "process.[/green]" ) return 0 # Dry-run: resolve fresh (no cache) and preview, mutating nothing. try: secrets, warnings = op_src.fetch_onepassword_secrets( references=references, account=account, token_env=token_env, binary_path=binary_path, use_cache=False, ) except RuntimeError as exc: console.print(f"[red]{exc}[/red]") return 1 override = bool(op_cfg.get("override_existing", True)) def action(name: str) -> str: if name == token_env: return "[dim]skip (token var)[/dim]" if name not in secrets: return "[red]unresolved (see warnings)[/red]" already = bool(os.environ.get(name)) if already and not override: return "[dim]skip (already set)[/dim]" return "[green]would export[/green]" + (" (overrides)" if already else "") print_table(console, (("Env var", {"style": "cyan"}), "Action"), ((name, action(name)) for name in sorted(references)), warnings) console.print( "\n This was a dry-run — references resolve automatically on the next " "[cyan]hermes[/cyan] invocation. Re-run with [cyan]--apply[/cyan] to export " "into the current shell instead." ) return 0 def cmd_disable(args: argparse.Namespace) -> int: return disable_secret_source( "onepassword", "[green]Disabled.[/green] 1Password references will NOT be resolved on the " "next Hermes invocation.\n" " Your reference mappings are left in config.yaml — remove them with " "[cyan]hermes secrets onepassword remove ENV_VAR[/cyan] if you no longer " "need them.", ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _op_whoami( binary: Path, account: str, *, token_value: str = "" ) -> Optional[str]: """Return a short identity string if op is authenticated, else None. ``token_value`` is passed to the child as ``OP_SERVICE_ACCOUNT_TOKEN`` so a candidate token can be probed without touching the caller's environment. """ cmd = [str(binary), "whoami"] if account: cmd += ["--account", account] env = secret_cli_env() if token_value: env["OP_SERVICE_ACCOUNT_TOKEN"] = token_value try: res = subprocess.run( cmd, env=env, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10 ) except (OSError, subprocess.TimeoutExpired): return None if res.returncode != 0: return None out = (res.stdout or "").strip() return out.replace("\n", " ")[:120] or "authenticated"