hermes-agent/tests/test_env_loader_secret_sources.py
teknium1 2d16ec7fb7 feat(secrets): pluggable SecretSource interface + multi-source orchestrator
Introduces a first-class secret-source contract so password managers
(Bitwarden today, 1Password next, third-party vaults as plugins) plug
into one orchestrated startup path instead of each hardcoding into
env_loader.

- agent/secret_sources/base.py: SecretSource ABC (fetch-only contract:
  never raises, never prompts, sync with orchestrator-enforced timeout),
  shared ErrorKind taxonomy, FetchResult, run_secret_cli() minimal-env
  subprocess helper, API versioning for plugin compatibility.
- agent/secret_sources/registry.py: registration gating (name/scheme
  uniqueness, api_version, shape), apply_all() orchestrator owning
  precedence (mapped-beats-bulk, first-claim-wins, override_existing
  never crosses sources, protected bootstrap tokens), conflict warnings,
  per-var provenance, per-source wall-clock timeout.
- Bitwarden converted to a registered BitwardenSource (bulk shape);
  behavior unchanged, apply_bitwarden_secrets kept as legacy shim.
- env_loader._apply_external_secret_sources now drives the orchestrator;
  provenance labels resolve through registry (e.g. '(from 1Password)').
- PluginContext.register_secret_source() for external backends.
- secrets.sources optional ordering key in DEFAULT_CONFIG + example.
- tests/secret_sources/: 47 new tests incl. reusable conformance kit
  (SecretSourceConformance) that plugin authors run against their source.
2026-07-06 04:58:07 -07:00

176 lines
6 KiB
Python

"""Tests for the secret-source tracking in ``hermes_cli.env_loader``.
These cover the small public surface that lets `hermes model` / `hermes setup`
label detected credentials with their origin ("from Bitwarden") so users
don't see an unexplained "credentials ✓" line when their .env is empty.
"""
from __future__ import annotations
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from hermes_cli import env_loader # noqa: E402
@pytest.fixture(autouse=True)
def _reset_sources():
"""Each test starts with a clean source map and applied-home guard."""
env_loader._SECRET_SOURCES.clear()
env_loader.reset_secret_source_cache()
yield
env_loader._SECRET_SOURCES.clear()
env_loader.reset_secret_source_cache()
def test_get_secret_source_returns_none_for_untracked_var():
assert env_loader.get_secret_source("ANTHROPIC_API_KEY") is None
def test_get_secret_source_returns_label_for_tracked_var():
env_loader._SECRET_SOURCES["ANTHROPIC_API_KEY"] = "bitwarden"
assert env_loader.get_secret_source("ANTHROPIC_API_KEY") == "bitwarden"
def test_format_secret_source_suffix_empty_for_untracked():
# Credentials from .env or the shell shouldn't add noise — the
# implicit case stays unlabeled.
assert env_loader.format_secret_source_suffix("ANTHROPIC_API_KEY") == ""
def test_format_secret_source_suffix_bitwarden_uses_proper_name():
env_loader._SECRET_SOURCES["ANTHROPIC_API_KEY"] = "bitwarden"
assert (
env_loader.format_secret_source_suffix("ANTHROPIC_API_KEY")
== " (from Bitwarden)"
)
def test_format_secret_source_suffix_generic_label_for_future_sources():
# Future-proofing: a new secret source (e.g. "vault") should still
# produce a sensible label without needing to edit every call site.
env_loader._SECRET_SOURCES["OPENAI_API_KEY"] = "vault"
assert (
env_loader.format_secret_source_suffix("OPENAI_API_KEY")
== " (from vault)"
)
def test_apply_external_secret_sources_records_bitwarden_origin(tmp_path, monkeypatch):
"""End-to-end: when the Bitwarden source fetches keys, applied vars
end up in ``_SECRET_SOURCES`` so the UI can label them."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.test-token")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
config_path = tmp_path / "config.yaml"
config_path.write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: test-project\n"
" access_token_env: BWS_ACCESS_TOKEN\n",
encoding="utf-8",
)
# Stub the fetch layer under the SecretSource adapter.
import agent.secret_sources.bitwarden as bw_module
monkeypatch.setattr(bw_module, "find_bws", lambda **_kw: Path("/fake/bws"))
monkeypatch.setattr(
bw_module,
"fetch_bitwarden_secrets",
lambda **_kw: ({"ANTHROPIC_API_KEY": "sk-ant-test"}, []),
)
from agent.secret_sources import registry as reg_module
reg_module._reset_registry_for_tests()
env_loader._apply_external_secret_sources(tmp_path)
assert env_loader.get_secret_source("ANTHROPIC_API_KEY") == "bitwarden"
assert (
env_loader.format_secret_source_suffix("ANTHROPIC_API_KEY")
== " (from Bitwarden)"
)
def test_apply_external_secret_sources_noop_when_disabled(tmp_path, monkeypatch):
"""Disabled Bitwarden config must not touch the source map."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
config_path = tmp_path / "config.yaml"
config_path.write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: false\n",
encoding="utf-8",
)
env_loader._apply_external_secret_sources(tmp_path)
assert env_loader.get_secret_source("ANTHROPIC_API_KEY") is None
def test_apply_external_secret_sources_dedupes_within_process(tmp_path, monkeypatch):
"""``load_hermes_dotenv()`` is called at module-import time from several
hot modules (cli.py, hermes_cli/main.py, run_agent.py, ...). The
Bitwarden status line previously printed once per call — 3-5x per
startup. The applied-home guard must short-circuit subsequent calls
so the heavy work (config re-parse, Bitwarden lookup, status print)
runs exactly once per HERMES_HOME per process.
"""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
monkeypatch.setenv("BWS_ACCESS_TOKEN", "0.test-token")
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
config_path = tmp_path / "config.yaml"
config_path.write_text(
"secrets:\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: test-project\n"
" access_token_env: BWS_ACCESS_TOKEN\n",
encoding="utf-8",
)
call_count = {"n": 0}
def _fake_fetch(**_kwargs):
call_count["n"] += 1
return {"ANTHROPIC_API_KEY": "sk-ant-test"}, []
import agent.secret_sources.bitwarden as bw_module
monkeypatch.setattr(bw_module, "find_bws", lambda **_kw: Path("/fake/bws"))
monkeypatch.setattr(bw_module, "fetch_bitwarden_secrets", _fake_fetch)
from agent.secret_sources import registry as reg_module
reg_module._reset_registry_for_tests()
# Five calls in a row, simulating module-import-time invocations from
# cli.py, hermes_cli/main.py, run_agent.py, trajectory_compressor.py,
# gateway/run.py. Only the first should actually call the backend.
for _ in range(5):
env_loader._apply_external_secret_sources(tmp_path)
assert call_count["n"] == 1, (
"Bitwarden backend was called {} time(s); expected exactly 1 — "
"the applied-home guard is broken.".format(call_count["n"])
)
# Source tracking still works after dedup.
assert env_loader.get_secret_source("ANTHROPIC_API_KEY") == "bitwarden"
# reset_secret_source_cache() forces a fresh pull on the next call.
env_loader.reset_secret_source_cache()
env_loader._apply_external_secret_sources(tmp_path)
assert call_count["n"] == 2