feat(secrets): rework command source as a registered SecretSource — no provider selector

Reworks the salvaged command module into a CommandSource(SecretSource)
registered as the third bundled source, composing with Bitwarden and
1Password through the apply_all() orchestrator — enable any combination
simultaneously.  The original PR's secrets.provider single-selector is
deliberately dropped: multi-source is first-class and a mutually
exclusive provider switch would regress that.

- fetch() only fetches; precedence/override/conflicts/environ writes stay
  in the orchestrator.  ErrorKind classification + remediation hints.
- apply_command_secrets() kept as a legacy shim (parser/security helpers
  unchanged: HERMES_SECRET_KEY data-only key passing, cross-key misroute
  guard, base64-padding disambiguation, timeout + output cap, structured-
  fields-only failure logging, stderr discarded).
- Dispatch tests rewritten for the registry path incl. an explicit
  two-sources-compose test; selector tests removed with the selector.
- cli-config.yaml.example + docs page (command.md), secrets index entry.
- contributors mapping for mvalentin@valensys.net -> 0xr00tf3rr3t.
This commit is contained in:
Teknium 2026-07-22 04:05:06 -07:00
parent 3d5dd8efa5
commit 4f0ee4d3ff
7 changed files with 252 additions and 89 deletions

View file

@ -43,6 +43,7 @@ from typing import Dict, Optional
# Reuse the exact result shape the bitwarden source returns so
# hermes_cli.env_loader can consume both providers identically.
from agent.secret_sources.base import ErrorKind, SecretSource
from agent.secret_sources.bitwarden import FetchResult
__all__ = [
@ -320,32 +321,24 @@ def apply_command_secrets(
"""Run the helper once at startup and set its KEY=VALUE output on
``os.environ``.
This is the function ``load_hermes_dotenv()`` calls after the .env
files have loaded. It is intentionally defensive any failure
degrades to an empty :class:`FetchResult`; it never raises.
Non-destructive by default: keys already present in the process env
(i.e. from the shell or .env) win unless ``override_existing`` is True
the same precedence as the bitwarden source.
``home_path`` is accepted for signature symmetry with
``apply_bitwarden_secrets`` (no disk cache is kept for this provider
the helper IS the cache, e.g. a tmpfs env file).
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
the startup path goes through :class:`CommandSource` + the registry
orchestrator instead (which owns precedence and the environ writes).
"""
result = FetchResult()
command = (command or "").strip()
if not command:
result.error = (
"secrets.provider is 'command' but secrets.command is empty. "
"Set the helper command in config.yaml under secrets.command."
"secrets.command.enabled is true but secrets.command.command is "
"empty. Set the helper command in config.yaml."
)
return result
if _is_windows():
result.warnings.append(
"the 'command' secret provider is POSIX-only (needs /bin/sh); "
"skipping on Windows — use the default 'env' provider instead"
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
return result
@ -383,3 +376,111 @@ def apply_command_secrets(
result.applied.append(key)
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class CommandSource(SecretSource):
"""User-configured helper command as a registered secret source.
Composes with the other sources (Bitwarden, 1Password, plugins) through
the ``apply_all()`` orchestrator enable any combination simultaneously;
there is deliberately NO single-provider selector. ``fetch()`` only
fetches: precedence, ``override_existing`` semantics, conflict warnings,
and the ``os.environ`` writes are the orchestrator's job.
Bulk shape: the helper enumerates a KEY=VALUE blob in one run. Config::
secrets:
command:
enabled: true
command: "cat /run/user/1000/hermes-secrets.env"
# or per-vault CLIs: keepassxc-cli / secret-tool / pass / gpg —
# anything fast and NON-interactive.
"""
name = "command"
label = "Command helper"
shape = "bulk"
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"command": {
"description": "Helper run via /bin/sh -c; must print a "
"KEY=VALUE blob on stdout",
"default": "",
},
"helper_timeout_seconds": {
"description": "Hard timeout for one helper run",
"default": _COMMAND_TIMEOUT_SECONDS,
},
"override_existing": {
"description": "Helper values overwrite .env/shell values",
"default": False,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
command = str(cfg.get("command") or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command "
"is empty. Set the helper command in config.yaml."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
if _is_windows():
result.error = (
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
try:
timeout = float(cfg.get("helper_timeout_seconds",
_COMMAND_TIMEOUT_SECONDS))
except (TypeError, ValueError):
timeout = _COMMAND_TIMEOUT_SECONDS
stdout = _run_helper(command, "", timeout, _MAX_OUTPUT_BYTES)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.error = (
"helper command failed (see structured fields above); "
"no secrets applied"
)
result.error_kind = ErrorKind.INTERNAL
return result
secrets = _parse_dotenv_map(stdout)
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing to apply"
)
return result
result.secrets = secrets
return result
def remediation(self, kind, cfg: dict) -> str:
if kind == ErrorKind.NOT_CONFIGURED:
return (
"Set secrets.command.command in config.yaml to a fast, "
"non-interactive helper that prints KEY=VALUE lines."
)
if kind == ErrorKind.INTERNAL:
return (
"Run the helper manually in a shell to see its real error — "
"Hermes discards helper stderr so diagnostics can't leak "
"secret material."
)
return super().remediation(kind, cfg)

View file

@ -175,6 +175,13 @@ def _ensure_builtin_sources() -> None:
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled 1Password secret source",
exc_info=True)
try:
from agent.secret_sources.command import CommandSource
register_source(CommandSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled command secret source",
exc_info=True)
def _reset_registry_for_tests() -> None:

View file

@ -1506,3 +1506,16 @@ updates:
# binary_path: "" # "" = resolve op via PATH; else absolute path
# cache_ttl_seconds: 300 # 0 disables BOTH cache layers
# override_existing: true # resolved values win over existing env
#
# # ---- Command helper (any CLI vault) --------------------------------------
# # Run a user-configured helper that prints KEY=VALUE lines on stdout —
# # works with any secret store that has a CLI: keepassxc-cli, secret-tool,
# # pass, gpg, or a script that cats a tmpfs env file. Composes with the
# # sources above (enable any combination). POSIX-only (needs /bin/sh).
# # The helper must be fast and NON-interactive (hard timeout, 1 MiB cap);
# # its stderr is discarded so diagnostics can't leak secret material.
# command:
# enabled: false
# command: "cat /run/user/1000/hermes-secrets.env"
# helper_timeout_seconds: 3
# override_existing: false # .env/shell win by default

View file

@ -0,0 +1 @@
0xr00tf3rr3t

View file

@ -275,15 +275,24 @@ def test_apply_empty_command_sets_error(tmp_path):
# ---------------------------------------------------------------------------
def test_dispatch_provider_command_applies_and_records_source(tmp_path, monkeypatch):
@pytest.fixture(autouse=True)
def _clean_registry():
from agent.secret_sources import registry
registry._reset_registry_for_tests()
yield
registry._reset_registry_for_tests()
def test_registry_command_source_applies_and_records_source(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
helper = _write_helper(
tmp_path, "printf 'CMDTEST_API_KEY=sk-dispatch\\nCMDTEST_TOKEN=tok-dispatch\\n'"
)
(tmp_path / "config.yaml").write_text(
"secrets:\n"
" provider: command\n"
f" command: {helper}\n",
" command:\n"
" enabled: true\n"
f" command: {helper}\n",
encoding="utf-8",
)
@ -292,18 +301,18 @@ def test_dispatch_provider_command_applies_and_records_source(tmp_path, monkeypa
assert os.environ.get("CMDTEST_API_KEY") == "sk-dispatch"
assert env_loader.get_secret_source("CMDTEST_API_KEY") == "command"
assert env_loader.get_secret_source("CMDTEST_TOKEN") == "command"
# Provenance suffix comes from the existing generic fallback.
assert (
env_loader.format_secret_source_suffix("CMDTEST_API_KEY")
== " (from command)"
== " (from Command helper)"
)
def test_dispatch_status_line_printed_once_per_home(tmp_path, monkeypatch, capsys):
def test_registry_status_line_printed_once_per_home(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
helper = _write_helper(tmp_path, "printf 'CMDTEST_API_KEY=sk-once\\n'")
(tmp_path / "config.yaml").write_text(
f"secrets:\n provider: command\n command: {helper}\n",
"secrets:\n command:\n enabled: true\n"
f" command: {helper}\n",
encoding="utf-8",
)
@ -311,14 +320,15 @@ def test_dispatch_status_line_printed_once_per_home(tmp_path, monkeypatch, capsy
env_loader._apply_external_secret_sources(tmp_path)
err = capsys.readouterr().err
assert err.count("Command secret source: applied 1 secret") == 1
assert err.count("Command helper: applied 1 secret") == 1
def test_dispatch_provider_env_is_noop(tmp_path, monkeypatch):
def test_registry_disabled_command_source_is_noop(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
helper = _write_helper(tmp_path, "printf 'CMDTEST_API_KEY=sk-should-not-load\\n'")
(tmp_path / "config.yaml").write_text(
f"secrets:\n provider: env\n command: {helper}\n",
"secrets:\n command:\n enabled: false\n"
f" command: {helper}\n",
encoding="utf-8",
)
@ -328,23 +338,10 @@ def test_dispatch_provider_env_is_noop(tmp_path, monkeypatch):
assert env_loader.get_secret_source("CMDTEST_API_KEY") is None
def test_dispatch_unset_provider_without_bitwarden_is_noop(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
helper = _write_helper(tmp_path, "printf 'CMDTEST_API_KEY=sk-no\\n'")
(tmp_path / "config.yaml").write_text(
f"secrets:\n command: {helper}\n", # no provider key at all
encoding="utf-8",
)
env_loader._apply_external_secret_sources(tmp_path)
assert "CMDTEST_API_KEY" not in os.environ
def test_dispatch_failing_helper_does_not_block_startup(tmp_path, monkeypatch):
def test_registry_failing_helper_does_not_block_startup(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(
"secrets:\n provider: command\n command: exit 9\n",
"secrets:\n command:\n enabled: true\n command: exit 9\n",
encoding="utf-8",
)
# Must not raise — config/helper errors never block startup.
@ -352,62 +349,55 @@ def test_dispatch_failing_helper_does_not_block_startup(tmp_path, monkeypatch):
assert env_loader.get_secret_source("CMDTEST_API_KEY") is None
def test_dispatch_backcompat_bitwarden_enabled_without_provider(tmp_path, monkeypatch):
"""Existing users with only `secrets.bitwarden.enabled: true` (no
`provider` key) must keep routing to bitwarden untouched."""
def test_registry_command_composes_with_other_sources(tmp_path, monkeypatch):
"""Multi-source is first-class: the command source and a second bulk
source both apply in ONE pass; first claim wins on a contested var."""
from agent.secret_sources import registry
from agent.secret_sources.base import FetchResult, SecretSource
class _OtherVault(SecretSource):
name = "othervault"
label = "Other Vault"
shape = "bulk"
def fetch(self, cfg, home_path):
res = FetchResult()
res.secrets = {
"CMDTEST_OTHER_KEY": "from-other",
"CMDTEST_API_KEY": "loser-second-claim",
}
return res
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
helper = _write_helper(tmp_path, "printf 'CMDTEST_API_KEY=sk-cmd\\n'")
(tmp_path / "config.yaml").write_text(
"secrets:\n"
" bitwarden:\n"
" sources: [command, othervault]\n"
" command:\n"
" enabled: true\n"
" project_id: test-project\n",
f" command: {helper}\n"
" othervault:\n"
" enabled: true\n",
encoding="utf-8",
)
from agent.secret_sources.bitwarden import FetchResult
import agent.secret_sources.bitwarden as bw_module
calls = {"n": 0}
def _fake_apply(**_kwargs):
calls["n"] += 1
return FetchResult(
secrets={"CMDTEST_API_KEY": "sk-bw"}, applied=["CMDTEST_API_KEY"]
)
monkeypatch.setattr(bw_module, "apply_bitwarden_secrets", _fake_apply)
registry._ensure_builtin_sources()
registry.register_source(_OtherVault())
env_loader._apply_external_secret_sources(tmp_path)
assert calls["n"] == 1, "back-compat path did not route to bitwarden"
assert env_loader.get_secret_source("CMDTEST_API_KEY") == "bitwarden"
def test_dispatch_explicit_provider_command_wins_over_bitwarden_block(
tmp_path, monkeypatch
):
"""When `provider: command` is explicit, the bitwarden block (even if
enabled) is not consulted."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
helper = _write_helper(tmp_path, "printf 'CMDTEST_API_KEY=sk-cmd-wins\\n'")
(tmp_path / "config.yaml").write_text(
"secrets:\n"
" provider: command\n"
f" command: {helper}\n"
" bitwarden:\n"
" enabled: true\n"
" project_id: test-project\n",
encoding="utf-8",
)
import agent.secret_sources.bitwarden as bw_module
def _fail_if_called(**_kwargs): # pragma: no cover - assertion guard
raise AssertionError("bitwarden must not be consulted when provider=command")
monkeypatch.setattr(bw_module, "apply_bitwarden_secrets", _fail_if_called)
env_loader._apply_external_secret_sources(tmp_path)
assert os.environ.get("CMDTEST_API_KEY") == "sk-cmd-wins"
assert os.environ.get("CMDTEST_API_KEY") == "sk-cmd" # command won
assert os.environ.get("CMDTEST_OTHER_KEY") == "from-other" # both ran
assert env_loader.get_secret_source("CMDTEST_API_KEY") == "command"
assert env_loader.get_secret_source("CMDTEST_OTHER_KEY") == "othervault"
monkeypatch.delenv("CMDTEST_OTHER_KEY", raising=False)
def test_registry_helper_error_prints_remediation(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(
"secrets:\n command:\n enabled: true\n command: ''\n",
encoding="utf-8",
)
env_loader._apply_external_secret_sources(tmp_path)
err = capsys.readouterr().err
assert "secrets.command.command" in err

View file

@ -0,0 +1,50 @@
# Command Helper Secret Source
Resolve credentials by running your own helper command at startup — any secret store with a CLI works: `keepassxc-cli`, `secret-tool` (GNOME Keyring), `pass`, `gpg`, Vaultwarden's CLI, or a script that cats a tmpfs env file. The helper prints `KEY=VALUE` lines on stdout; Hermes applies them through the same orchestrator as [Bitwarden](./bitwarden) and [1Password](./onepassword), so you can enable any combination of sources simultaneously.
## How it works
1. You configure a helper command in `config.yaml` (never in `.env` — the command is configuration, `.env` holds values).
2. At startup, after `.env` loads, Hermes runs the helper ONCE via `/bin/sh -c` and parses its stdout as a dotenv blob.
3. The parsed keys flow through the standard precedence ladder: `.env`/shell win unless `override_existing: true`; mapped sources beat this bulk source on contested vars; first claim wins.
```yaml
secrets:
command:
enabled: true
command: "cat /run/user/1000/hermes-secrets.env"
# or any vault CLI that dumps KEY=VALUE lines:
# command: "pass show hermes/env"
# command: "secret-tool lookup service hermes-env"
```
## Config
| Key | Default | What it does |
|---|---|---|
| `enabled` | `false` | Master switch. |
| `command` | `""` | Helper run via `/bin/sh -c`; must print `KEY=VALUE` lines on stdout. |
| `helper_timeout_seconds` | `3` | Hard timeout for one helper run. Deliberately tight — the helper must be fast and NON-interactive (no unlock prompts, no touch/PIN). |
| `override_existing` | `false` | Helper values overwrite `.env`/shell values. Off by default (unlike Bitwarden/1Password) since a local helper is not a central rotation authority. |
## Security model
- The helper command string is YOUR configuration — same trust level as the `.env` file you control.
- Output is hard-capped at 1 MiB; a runaway helper can't wedge startup (process group killed on timeout).
- The helper's **stderr is discarded** — vault CLI diagnostics can carry secret material, so they never reach Hermes' output. Failures log structured fields only (exit code / signal / errno), never the command string.
- Whitespace-only values are treated as "no value" — a placeholder entry never flows into an Authorization header.
- POSIX-only (needs `/bin/sh`). On Windows the source reports itself unconfigured and startup continues.
## Failure modes
Startup is never blocked. Errors print one line plus a `→` remediation hint:
| Symptom | Cause | Fix |
|---|---|---|
| `secrets.command.command is empty` | Enabled without a command | Set `secrets.command.command` in config.yaml |
| `helper command failed` | Non-zero exit, timeout, spawn failure | Run the helper manually in a shell to see its real error (Hermes discards its stderr on purpose) |
| `helper output was not a KEY=VALUE map` | Helper printed a bare value or garbage | Make the helper emit dotenv-shaped lines |
## When to use this vs a plugin
The command source is the escape hatch for vaults without a bundled integration. If you find yourself wrapping a complex CLI dance in a long script, consider a proper [secret-source plugin](/developer-guide/secret-source-plugin) instead — plugins get caching, provenance labels, and typed config.

View file

@ -6,6 +6,7 @@ Supported:
- [Bitwarden Secrets Manager](./bitwarden) — `bws` CLI, lazy-installed, free tier works.
- [1Password](./onepassword) — `op://` references via the official `op` CLI; service-account or desktop session auth.
- [Command helper](./command) — any CLI vault (`keepassxc-cli`, `secret-tool`, `pass`, custom scripts) via a user-configured helper that prints `KEY=VALUE` lines.
## Multiple sources at once