diff --git a/agent/secret_sources/command.py b/agent/secret_sources/command.py index 72603977c2ba..d9047c01ab36 100644 --- a/agent/secret_sources/command.py +++ b/agent/secret_sources/command.py @@ -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) diff --git a/agent/secret_sources/registry.py b/agent/secret_sources/registry.py index 8c07b33fbcd5..f81b8a35dd3f 100644 --- a/agent/secret_sources/registry.py +++ b/agent/secret_sources/registry.py @@ -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: diff --git a/cli-config.yaml.example b/cli-config.yaml.example index 5e121904ca4a..e1fd31e6ffe5 100644 --- a/cli-config.yaml.example +++ b/cli-config.yaml.example @@ -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 diff --git a/contributors/emails/mvalentin@valensys.net b/contributors/emails/mvalentin@valensys.net new file mode 100644 index 000000000000..b0fd7761e738 --- /dev/null +++ b/contributors/emails/mvalentin@valensys.net @@ -0,0 +1 @@ +0xr00tf3rr3t diff --git a/tests/test_command_secret_source.py b/tests/test_command_secret_source.py index d3e7f652b93c..fbf20424c424 100644 --- a/tests/test_command_secret_source.py +++ b/tests/test_command_secret_source.py @@ -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 diff --git a/website/docs/user-guide/secrets/command.md b/website/docs/user-guide/secrets/command.md new file mode 100644 index 000000000000..04184eb95de5 --- /dev/null +++ b/website/docs/user-guide/secrets/command.md @@ -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. diff --git a/website/docs/user-guide/secrets/index.md b/website/docs/user-guide/secrets/index.md index 05c1bd52a8bd..427d50c1ea39 100644 --- a/website/docs/user-guide/secrets/index.md +++ b/website/docs/user-guide/secrets/index.md @@ -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