From 8e089db689da1286a6b05fc42a8acf2dafdb73e5 Mon Sep 17 00:00:00 2001 From: izumi0uu Date: Sat, 6 Jun 2026 12:02:07 +0800 Subject: [PATCH] fix(secrets): validate bitwarden status token Keep the env-presence row, but add a real Bitwarden probe so revoked or malformed tokens no longer look healthy in hermes secrets bitwarden status. Also document the new status behavior and lock it in with a dedicated regression test. Refs: NousResearch/hermes-agent#40275 Tested: ./scripts/run_tests.sh tests/hermes_cli/test_bitwarden_status.py tests/test_bitwarden_secrets.py Tested: .venv/bin/python -m ruff check hermes_cli/secrets_cli.py tests/hermes_cli/test_bitwarden_status.py --- hermes_cli/secrets_cli.py | 54 ++++++++- tests/hermes_cli/test_bitwarden_status.py | 110 ++++++++++++++++++ website/docs/reference/cli-commands.md | 2 +- website/docs/user-guide/secrets/bitwarden.md | 2 +- .../current/user-guide/secrets/bitwarden.md | 4 +- 5 files changed, 164 insertions(+), 8 deletions(-) create mode 100644 tests/hermes_cli/test_bitwarden_status.py diff --git a/hermes_cli/secrets_cli.py b/hermes_cli/secrets_cli.py index d457ff7ecfdd..12710a5ca319 100644 --- a/hermes_cli/secrets_cli.py +++ b/hermes_cli/secrets_cli.py @@ -2,7 +2,7 @@ Subcommands: setup — interactive wizard: install bws, prompt for token + project, test fetch - status — show current config + binary version + last fetch outcome + status — show current config + binary version + token validation status sync — run a fetch right now and show what would be applied (dry-run friendly) disable — flip ``secrets.bitwarden.enabled`` to False install — just download the bws binary (no token / project required) @@ -11,6 +11,7 @@ Subcommands: from __future__ import annotations import argparse +import io import json import os import subprocess @@ -68,7 +69,10 @@ def register_cli(parent_parser: argparse.ArgumentParser) -> None: ) setup.set_defaults(func=cmd_setup) - status = sub.add_parser("status", help="Show config + binary + last fetch") + status = sub.add_parser( + "status", + help="Show config + binary + token validation status", + ) status.set_defaults(func=cmd_status) token = sub.add_parser( @@ -312,7 +316,15 @@ def cmd_status(args: argparse.Namespace) -> int: token_env = bw_cfg.get("access_token_env", "BWS_ACCESS_TOKEN") project_id = bw_cfg.get("project_id", "") server_url = str(bw_cfg.get("server_url", "") or "").strip() - token_set = bool(os.environ.get(token_env)) + 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, + ) table = Table(show_header=False, box=None, padding=(0, 2)) table.add_column("", style="bold") @@ -320,6 +332,7 @@ def cmd_status(args: argparse.Namespace) -> int: table.add_row("Enabled", _yn(enabled)) table.add_row("Token env var", token_env) table.add_row("Token in env", _yn(token_set)) + table.add_row("Token validation", token_validation) table.add_row("Project ID", project_id or "[dim](unset)[/dim]") table.add_row( "Server URL", @@ -329,13 +342,14 @@ def cmd_status(args: argparse.Namespace) -> int: table.add_row("Cache TTL (s)", str(bw_cfg.get("cache_ttl_seconds", 300))) table.add_row("Auto-install", _yn(bool(bw_cfg.get("auto_install", True)))) - binary = bw.find_bws(install_if_missing=False) if binary: table.add_row("bws binary", f"{binary} ({_bws_version(binary)})") else: table.add_row("bws binary", "[yellow]not installed[/yellow]") console.print(Panel(table, title="Bitwarden Secrets Manager", border_style="cyan")) + for message in validation_messages: + console.print(message) if not enabled: console.print("\n Run [cyan]hermes secrets bitwarden setup[/cyan] to enable.") @@ -558,6 +572,38 @@ def _bws_version(binary: Path) -> str: return "version unknown" +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( + " [yellow]Warning: token doesn't start with '0.' — usually that means " + "you pasted something other than a BSM access token. Continuing anyway.[/yellow]" + ) + + 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]]: diff --git a/tests/hermes_cli/test_bitwarden_status.py b/tests/hermes_cli/test_bitwarden_status.py new file mode 100644 index 000000000000..d9a86305ffe4 --- /dev/null +++ b/tests/hermes_cli/test_bitwarden_status.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path + + +def _bitwarden_config(*, enabled: bool = True, server_url: str = "") -> dict: + return { + "secrets": { + "bitwarden": { + "enabled": enabled, + "access_token_env": "BWS_ACCESS_TOKEN", + "project_id": "proj-123", + "server_url": server_url, + "cache_ttl_seconds": 300, + "override_existing": True, + "auto_install": True, + } + } + } + + +def test_status_surfaces_failed_token_validation(monkeypatch, capsys): + from hermes_cli import secrets_cli + + seen = {} + + monkeypatch.setattr( + secrets_cli, + "load_config", + lambda: _bitwarden_config(server_url="https://vault.bitwarden.eu"), + ) + monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.invalid-token") + monkeypatch.setattr( + secrets_cli.bw, + "find_bws", + lambda install_if_missing=False: Path("/tmp/bws"), + ) + monkeypatch.setattr(secrets_cli, "_bws_version", lambda _binary: "bws v2.1.0") + + def _fake_list_projects(binary, token, console, *, server_url=""): + seen["binary"] = binary + seen["token"] = token + seen["server_url"] = server_url + console.print(" bws project list failed: Doesn't contain a decryption key") + console.print( + " This usually means the access token is wrong or revoked. " + "Double-check it in the Bitwarden web app." + ) + return None + + monkeypatch.setattr(secrets_cli, "_list_projects", _fake_list_projects) + + assert secrets_cli.cmd_status(Namespace()) == 0 + + out = capsys.readouterr().out + assert "Token in env" in out + assert "Token validation" in out + assert "failed" in out + assert "Doesn't contain a decryption key" in out + assert "wrong or revoked" in out + assert seen == { + "binary": Path("/tmp/bws"), + "token": "0.invalid-token", + "server_url": "https://vault.bitwarden.eu", + } + + +def test_status_warns_when_token_does_not_look_like_bsm_token(monkeypatch, capsys): + from hermes_cli import secrets_cli + + monkeypatch.setattr(secrets_cli, "load_config", lambda: _bitwarden_config()) + monkeypatch.setenv("BWS_ACCESS_TOKEN", "not-a-bitwarden-token") + monkeypatch.setattr( + secrets_cli.bw, + "find_bws", + lambda install_if_missing=False: Path("/tmp/bws"), + ) + monkeypatch.setattr(secrets_cli, "_bws_version", lambda _binary: "bws v2.1.0") + monkeypatch.setattr( + secrets_cli, + "_list_projects", + lambda binary, token, console, *, server_url="": [], + ) + + assert secrets_cli.cmd_status(Namespace()) == 0 + + out = capsys.readouterr().out + assert "Token validation" in out + assert "passed" in out + assert "doesn't start with '0.'" in out + + +def test_status_marks_validation_as_not_checked_without_bws_binary(monkeypatch, capsys): + from hermes_cli import secrets_cli + + monkeypatch.setattr(secrets_cli, "load_config", lambda: _bitwarden_config()) + monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.token-present") + monkeypatch.setattr( + secrets_cli.bw, + "find_bws", + lambda install_if_missing=False: None, + ) + + assert secrets_cli.cmd_status(Namespace()) == 0 + + out = capsys.readouterr().out + assert "Token validation" in out + assert "not checked" in out + assert "bws not installed" in out diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index b222039715fd..b38b87512dd9 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -431,7 +431,7 @@ Pull API keys from an external secret manager at process startup instead of stor | Subcommand | Description | |------------|-------------| | `setup` | Interactive wizard: install the pinned `bws` binary, store an access token, and pick a project. Accepts `--project-id`, `--access-token`, and `--server-url` for non-interactive use. | -| `status` | Show current config, binary path/version, and last fetch info. | +| `status` | Show current config, binary path/version, and token validation status. | | `token` | Rotate the access token: validates the new token against Bitwarden before storing it in `.env` (a rejected token changes nothing). Accepts `--access-token` for non-interactive use and `--no-verify` to skip the probe. | | `sync` | Fetch secrets now and report what changed. Add `--apply` to actually export the secrets into the current shell's environment (default is dry-run). | | `install` | Download and verify the pinned `bws` binary. `--force` re-downloads even if a managed copy already exists. | diff --git a/website/docs/user-guide/secrets/bitwarden.md b/website/docs/user-guide/secrets/bitwarden.md index b48d20d789a2..991132a6fa10 100644 --- a/website/docs/user-guide/secrets/bitwarden.md +++ b/website/docs/user-guide/secrets/bitwarden.md @@ -68,7 +68,7 @@ From now on, every `hermes` invocation pulls fresh secrets at startup. You'll se | Command | What it does | |---|---| | `hermes secrets bitwarden setup` | Interactive wizard (install binary, prompt for token, pick project, test fetch) | -| `hermes secrets bitwarden status` | Show config + binary version + token presence | +| `hermes secrets bitwarden status` | Show config + binary version + token presence/validation | | `hermes secrets bitwarden token` | Rotate the access token: validate the new token against Bitwarden, then store it in `.env` | | `hermes secrets bitwarden sync` | Dry-run: pull secrets now and show what would be applied | | `hermes secrets bitwarden sync --apply` | Pull and export into the current shell's environment | diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md index 69871dbe2288..c58ed0bc00e2 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/user-guide/secrets/bitwarden.md @@ -68,7 +68,7 @@ hermes secrets bitwarden status | 命令 | 功能 | |---|---| | `hermes secrets bitwarden setup` | 交互式向导(安装二进制文件、提示输入令牌、选择项目、测试拉取) | -| `hermes secrets bitwarden status` | 显示配置、二进制版本及令牌是否存在 | +| `hermes secrets bitwarden status` | 显示配置、二进制版本,以及令牌是否存在/是否通过校验 | | `hermes secrets bitwarden token` | 轮换访问令牌:先向 Bitwarden 验证新令牌,验证通过后再写入 `.env` | | `hermes secrets bitwarden sync` | 演习模式:立即拉取 secret 并显示将应用的内容 | | `hermes secrets bitwarden sync --apply` | 拉取并导出到当前 shell 的环境中 | @@ -140,4 +140,4 @@ Bitwarden 永远不会阻塞 Hermes 启动。如果出现任何问题,stderr - **无法访问 `api.bitwarden.com` 的隔离环境**。 - **CI/CD** 场景,已有现成的 secret 注入机制(GitHub Actions secrets、Vault 等)——选择一种方式,不要两者并用。 -适合使用此功能的场景:多机器集群、共享开发机、gateway VPS,或任何需要跨多个 Hermes 安装进行集中轮换和吊销管理的场景。 \ No newline at end of file +适合使用此功能的场景:多机器集群、共享开发机、gateway VPS,或任何需要跨多个 Hermes 安装进行集中轮换和吊销管理的场景。