mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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
This commit is contained in:
parent
fe5d0be6db
commit
8e089db689
5 changed files with 164 additions and 8 deletions
|
|
@ -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]]:
|
||||
|
|
|
|||
110
tests/hermes_cli/test_bitwarden_status.py
Normal file
110
tests/hermes_cli/test_bitwarden_status.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 安装进行集中轮换和吊销管理的场景。
|
||||
适合使用此功能的场景:多机器集群、共享开发机、gateway VPS,或任何需要跨多个 Hermes 安装进行集中轮换和吊销管理的场景。
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue