mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
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:
parent
3d5dd8efa5
commit
4f0ee4d3ff
7 changed files with 252 additions and 89 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
contributors/emails/mvalentin@valensys.net
Normal file
1
contributors/emails/mvalentin@valensys.net
Normal file
|
|
@ -0,0 +1 @@
|
|||
0xr00tf3rr3t
|
||||
|
|
@ -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
|
||||
|
|
|
|||
50
website/docs/user-guide/secrets/command.md
Normal file
50
website/docs/user-guide/secrets/command.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue