Skip to content

feat(secrets): add command secret source + unified secrets.provider selector - #44509

Closed
0xr00tf3rr3t wants to merge 3 commits into
NousResearch:mainfrom
0xr00tf3rr3t:feat/secrets-command-source
Closed

feat(secrets): add command secret source + unified secrets.provider selector#44509
0xr00tf3rr3t wants to merge 3 commits into
NousResearch:mainfrom
0xr00tf3rr3t:feat/secrets-command-source

Conversation

@0xr00tf3rr3t

Copy link
Copy Markdown
Contributor

What does this PR do?

Adds a command secret source: at startup Hermes can resolve credentials by running a user-configured helper (e.g. keepassxc-cli, secret-tool, pass, or a script that cats a tmpfs env file) instead of keeping keys in plaintext ~/.hermes/.env. The helper prints either a bare value or a KEY=VALUE dotenv blob on stdout; its output is parsed and applied to the process env using the same non-destructive precedence as the existing Bitwarden source (process env / .env win).

It also introduces a single unified selector, secrets.provider (env | command | bitwarden), so there is one source-of-truth for which backend supplies secrets — rather than each backend having its own ad-hoc enabled flag. env is the default, so existing users see no change, and a config that only sets secrets.bitwarden.enabled: true is treated as provider: bitwarden for full backward compatibility.

This extends the existing agent/secret_sources/ framework (the same one bitwarden.py lives in) rather than adding a parallel mechanism — aligning with the secrets roadmap in #3630 and complementing the vendor-specific source requests (Infisical #22791, 1Password #36949, Proton Pass #30649, Vaultwarden #33126). A generic command source is the lowest-common-denominator backend: any secret store with a CLI works through it without bespoke integration.

The security model is ported line-for-line from the Hermes desktop app's TypeScript command provider, so the two surfaces resolve secrets identically.

Related Issue

Fixes #44507 (generic command/exec secret source backend).

Also relates to #3630 (feat(secrets): Phase 4 — Advanced Security / External Vaults): this adds a generic command/exec backend to the secret_sources framework that complements the vendor-specific source requests (Infisical #22791, 1Password #36949, Proton Pass #30649, Vaultwarden #33126).

Type of Change

  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix (the source is hardened against shell injection, credential misrouting, and secret-in-log leakage — see Changes Made)

Changes Made

  • agent/secret_sources/command.py (new): apply_command_secrets() + get_command_secret() / list_command_secrets() and the parse_secret_output() / unquote_dotenv_value() parsers. Security properties:
    • The requested key travels ONLY in the HERMES_SECRET_KEY env var — never interpolated into the /bin/sh -c string — so a hostile key name is inert data, not code.
    • Cross-key misroute guard: a helper emitting a single env-shaped line for a different key resolves to None rather than leaking another key's value into the wanted key's Authorization header.
    • Base64 =-padding disambiguation so a bare base64 secret isn't misclassified as a dotenv line.
    • Hard 3s timeout (kills the whole process group via killpg, so a forking helper can't hold the pipe open), 1 MiB output cap; every failure degrades to "no value" and never raises.
    • Logs ONLY structured fields (code=/signal=/errno=); the helper's stderr is piped and discarded; the command string and secret values are never logged.
    • Reuses bitwarden.py's FetchResult so env_loader consumes both sources identically.
  • hermes_cli/env_loader.py: _apply_external_secret_sources() now reads the unified secrets.provider selector and routes to command / bitwarden / env (no-op). Bitwarden behavior is unchanged; back-compat maps a bare secrets.bitwarden.enabled: true to provider: bitwarden. Provenance is recorded as command in _SECRET_SOURCES (the existing format_secret_source_suffix() already labels it "(from command)" generically — not duplicated).
  • tests/test_command_secret_source.py (new, 27 tests).
  • cli-config.yaml.example: documented secrets: block (provider / command / bitwarden) — the config keys this PR introduces.

How to Test

  1. Make a helper script and point config at it:
    mkdir -p /tmp/sx && cat > /tmp/sx/helper.sh <<'EOF'
    #!/bin/sh
    printf 'OPENROUTER_API_KEY=sk-test-value\n'
    EOF
    chmod +x /tmp/sx/helper.sh
    In ~/.hermes/config.yaml:
    secrets:
      provider: command
      command: /tmp/sx/helper.sh
  2. Start any Hermes entry point (hermes chat -q "hi"); on stderr you'll see Command secret source: applied 1 secret (OPENROUTER_API_KEY). The key is now in the process env without ever touching .env.
  3. Run the suite (CI parity): scripts/run_tests.sh (or pytest tests/test_command_secret_source.py tests/test_env_loader_secret_sources.py tests/test_bitwarden_secrets.py -q). Result: 27 new tests pass; the 50 existing secret/env-loader tests stay green. Wider -k "env_loader or secret or secrets" surface: 229 passed / 5 skipped, no regression.

The new tests exercise the real path (real chmod +x helpers, real temp HERMES_HOME, real subprocess — not mocks) per the rubric: bare/dotenv/base64 round-trip, cross-key misroute, injection-inert key (canary file NOT created), timeout kill within bound, non-zero-exit degrade, no-secret-in-logs, precedence/override, the provider: command dispatch via a real config.yaml, idempotency, and back-compat bitwarden routing.

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(secrets):)
  • I searched existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this feature (3 files, +859/-1)
  • I've run the suite and all tests pass (27 new + 50 baseline; 229 in the wider secrets surface)
  • I've added tests for my changes
  • I've tested on my platform: Ubuntu (Linux)

Documentation & Housekeeping

  • I've updated cli-config.yaml.example — added a documented secrets: block (provider / command / bitwarden), since this PR introduces those config keys. (The file previously had no secrets: section.)
  • I've updated docstrings (module + functions document the security model)
  • I've considered cross-platform impact: the command source is POSIX-only (needs /bin/sh). On Windows it degrades cleanly to an empty result with a warning and the user stays on the default env provider — mirroring the desktop provider's Windows degrade. No behavior change for non-Windows users.
  • README / docs/ — N/A (no user-facing doc page yet; can add a short note if desired)

Screenshots / Logs

$ hermes chat -q "hi"   # with provider: command configured
  Command secret source: applied 1 secret (OPENROUTER_API_KEY)

(Resolved values are never printed — only key names and counts.)

… selector

Brings the agent's secret-source system to parity with the desktop app's
`command` secrets provider (hermes-desktop src/main/secrets/commandProvider.ts),
so a vault helper configured for the desktop also resolves on the gateway/CLI.

NEW agent/secret_sources/command.py — ports the TS provider's security model:
- Runs a user-configured helper via `/bin/sh -c`; the requested key travels
  ONLY in the HERMES_SECRET_KEY env var, never interpolated into the command
  string, so a hostile key name is inert data (not code).
- parse_secret_output mirrors the TS parser: exact dotenv-key match wins; >=2
  env-shaped lines without the wanted key -> None; otherwise a bare value;
  base64 '='-padding disambiguation; cross-key misroute guard (a single
  OTHER_KEY=realvalue line never leaks into a different wanted key).
- Hard 3s timeout (kills the whole process group via killpg, so a forking
  helper can't keep the pipe open), 1 MiB output cap, POSIX-only (Windows
  degrades to an empty result + warning). Every failure degrades to "no value";
  it never raises and never blocks startup.
- Logs ONLY structured fields (code=/signal=/errno=) to stderr; the helper's
  stderr is piped and DISCARDED; the command string and secret values are
  never logged. Reuses bitwarden.py's FetchResult so env_loader consumes both
  sources identically.

hermes_cli/env_loader.py — _apply_external_secret_sources now reads a unified
`secrets.provider` selector ("env" | "command" | "bitwarden"):
- provider=command routes to apply_command_secrets, records the provenance as
  "command" in _SECRET_SOURCES (so format_secret_source_suffix labels keys
  "(from command)" — already generic, not duplicated), and re-runs the ASCII
  credential sanitizer like the bitwarden path.
- provider=bitwarden keeps the existing behavior byte-for-byte.
- env / unset is a no-op (today's default — zero change for existing users).
- BACK-COMPAT: a config with only `secrets.bitwarden.enabled: true` and no
  `provider` key is treated as provider=bitwarden, so existing Bitwarden users
  are unaffected.

Config (the provider selector, command path, timeouts) lives in config.yaml
under `secrets:` per the project rubric — only resolved secret VALUES touch env.

Tests: NEW tests/test_command_secret_source.py — 27 cases, E2E against a real
temp HERMES_HOME with real chmod+x shell helpers (not mocks): bare/dotenv/
base64 round-trip, cross-key misroute, injection-inert key (canary not
created), timeout kill within bound, non-zero-exit degrade, no-secret-in-logs,
precedence/override, dispatch via config.yaml provider:command, idempotency,
and back-compat bitwarden routing. 27 new + 50 baseline green; wider
secrets/env_loader/config surface 229 passed / 5 skipped, no regression.
….example

The command-source PR introduces the `secrets.provider` (env|command|bitwarden)
selector and `secrets.command` config keys, but cli-config.yaml.example had no
`secrets:` section at all. Add a fully-commented block documenting all three
providers — the `command` helper (with the HERMES_SECRET_KEY-as-data contract,
POSIX-only note, and timeout/cap), the unified selector, and the previously
undocumented bitwarden options — so users can discover the feature from the
example config. Comments only; no active YAML, no behavior change.
Parity fix for the same gap a review pass (Greptile) caught in the desktop
TS provider this source was ported from: _parse_dotenv_map (the list/
enumerate path) stored whitespace-only unquoted values, while
parse_secret_output / get_command_secret resolve them to None. A quoted-blank
vault placeholder (BLANK="   ") would therefore appear as a configured key in
list_command_secrets() but resolve None on read — the two disagreed on
whether the key is set. _parse_dotenv_map now drops whitespace-only entries,
so the list and get paths agree. (apply_command_secrets already skipped them
on apply; this fixes the public list/enumerate API at the source.)

Regression test asserts list_command_secrets() omits a quoted-blank entry,
keeps the real key, and agrees with get_command_secret(). 28 command-source
tests + 50 baseline green.
@liuhao1024

Copy link
Copy Markdown
Contributor

Verification: Clean implementation, thorough security design

This is a well-executed port of the desktop TS provider. The security model is solid:

  1. Key isolationHERMES_SECRET_KEY env var, never interpolated into the shell string; hostile key names are inert data (verified by test_hostile_key_name_is_inert_data)
  2. Process group cleanupstart_new_session=True + os.killpg prevents hung helpers from leaking child processes
  3. Fail-degrade — every failure path (timeout, non-zero exit, spawn failure, oversized output) returns None/empty, never raises
  4. No secret leakage — stderr is piped and discarded; failure logs contain only structured fields (exit code, signal), never the command string or helper output
  5. Cross-key misroute guardparse_secret_output correctly handles the single-env-shaped-line-for-wrong-key case, with base64-padding disambiguation
  6. Startup-once designapply_command_secrets runs the helper exactly once; per-key resolution via get_command_secret is a separate opt-in path

Test coverage is comprehensive: 20+ tests covering hostile keys, timeout degradation, precedence, dotenv parsing, dispatch E2E, and back-compat with bitwarden.

@alt-glitch alt-glitch added type/feature New feature or request area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have labels Jun 11, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough parser and failure-path coverage. The generic command use case remains unimplemented on current main, but this branch predates the SecretSource consolidation in 2d16ec7fb and cannot be salvaged mechanically.

Problems

  • Current startup uses agent.secret_sources.registry.apply_all() through hermes_cli/env_loader.py:305-375; it composes all enabled sources with mapped-vs-bulk precedence. The PR's exclusive secrets.provider branches at hermes_cli/env_loader.py:288-295 bypass that behavior.
  • Current sources must only fetch; the orchestrator owns os.environ writes (agent/secret_sources/base.py:24-28). apply_command_secrets() writes environment values directly at agent/secret_sources/command.py:380-390.
  • _run_helper() copies the full credential-bearing environment and invokes /bin/sh -c (agent/secret_sources/command.py:181-187), contrary to the shared minimal-environment, argv-only helper in agent/secret_sources/base.py:216-265.

Suggested changes

  • Rework this as a mapped CommandSource registered with the existing registry, using secrets.command.enabled and secrets.sources, and return a FetchResult for the orchestrator to apply.
  • Use the shared subprocess safety model, update DEFAULT_CONFIG, and cover conformance plus coexistence with the bundled sources.

Automated hermes-sweeper review.

Comment thread hermes_cli/env_loader.py
if not provider:
provider = "bitwarden" if bw_cfg.get("enabled") else "env"

if provider == "command":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main composes every enabled source through registry.apply_all() with mapped-vs-bulk precedence and conflict reporting. An exclusive provider selector would bypass that architecture; please rework command as a registered source under secrets.sources rather than adding this branch.

)
return None

env = os.environ.copy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do not pass the full post-dotenv environment to a secret helper: it contains every credential Hermes knows. Current main's run_secret_cli() uses a minimal allowlisted environment and argv-only execution; the command source needs to adopt that contract.

@teknium1 teknium1 added sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 14, 2026
@teknium1

Copy link
Copy Markdown
Contributor

Merged via #69266 — your commit landed as-is (rebase-merged, authorship preserved), with one design change on top: the secrets.provider single-selector was dropped, and the command source is instead registered as the third bundled SecretSource composing with Bitwarden + 1Password through the apply_all() orchestrator (multi-source simultaneously is first-class in the secrets design — a mutually-exclusive selector would have regressed it; there's now a regression test running your source plus a second vault in one pass). Your security engineering — HERMES_SECRET_KEY data-only key passing, discarded helper stderr, the cross-key misroute guard, base64-padding disambiguation — carried over verbatim with its full test suite. Closes #44507 and #57062. Thanks @0xr00tf3rr3t!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/auth Authentication, OAuth, credential pools comp/agent Core agent runtime: loop, agent_init, prompt builder, context-compression, responses endpoint comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: generic command/exec secret source backend

4 participants