hermes-vault-secret-source 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cryptoyasenka
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-vault-secret-source
3
+ Version: 0.1.0
4
+ Summary: HashiCorp Vault secret source for Hermes Agent (bulk KV v2).
5
+ Author: cryptoyasenka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cryptoyasenka/hermes-vault-secret-source
8
+ Project-URL: Repository, https://github.com/cryptoyasenka/hermes-vault-secret-source
9
+ Project-URL: Issues, https://github.com/cryptoyasenka/hermes-vault-secret-source/issues
10
+ Keywords: hermes,hermes-agent,vault,hashicorp,secrets,plugin
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: hvac>=2.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=7; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # hermes-vault-secret-source
30
+
31
+ [![CI](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml/badge.svg)](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml)
32
+
33
+ A [HashiCorp Vault](https://developer.hashicorp.com/vault) secret source for
34
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent), packaged as a
35
+ standalone pip plugin.
36
+
37
+ At Hermes startup it reads one Vault **KV v2** secret path and injects every
38
+ key found there as an environment variable, the same way the bundled Bitwarden
39
+ Secrets Manager source injects a whole project. This is a **bulk** source, so
40
+ explicit per-variable bindings from mapped sources (like 1Password) take
41
+ precedence over it.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install "hermes-vault-secret-source @ git+https://github.com/cryptoyasenka/hermes-vault-secret-source"
47
+ ```
48
+
49
+ The package is not on PyPI yet, so install it straight from the repository as
50
+ shown above. Once it is published, this simplifies to:
51
+
52
+ ```bash
53
+ pip install hermes-vault-secret-source
54
+ ```
55
+
56
+ Either way this pulls in [`hvac`](https://hvac.readthedocs.io/), the official
57
+ Python client for Vault. Install it into the same environment as `hermes-agent`.
58
+
59
+ Hermes discovers the plugin automatically through the `hermes_agent.plugins`
60
+ entry point, but plugins are opt-in. Enable it once:
61
+
62
+ ```bash
63
+ hermes plugins enable hermes-hashicorp-vault
64
+ ```
65
+
66
+ ## Configure
67
+
68
+ Add a `vault` block under `secrets` in your Hermes config:
69
+
70
+ ```yaml
71
+ secrets:
72
+ vault:
73
+ enabled: true
74
+ # addr is read from $VAULT_ADDR by default; set `addr` to hard-code it.
75
+ path: apps/my-agent # KV v2 path whose keys become env vars
76
+ mount_point: secret # KV v2 mount (default: secret)
77
+ auth_method: token # "token" (default) or "approle"
78
+ cache_ttl_seconds: 300 # 0 disables caching
79
+ ```
80
+
81
+ Credentials are never written into config; they come from environment
82
+ variables:
83
+
84
+ | Auth method | Env vars used |
85
+ |-------------|--------------------------------------------------|
86
+ | `token` | `VAULT_ADDR`, `VAULT_TOKEN` |
87
+ | `approle` | `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID` |
88
+
89
+ The env-var names are overridable per field (`addr_env`, `token_env`,
90
+ `role_id_env`, `secret_id_env`) if your deployment uses different names.
91
+
92
+ ### All config keys
93
+
94
+ | Key | Default | Meaning |
95
+ |---------------------------|----------------|-------------------------------------------------|
96
+ | `enabled` | `false` | Master switch |
97
+ | `addr` | `""` | Vault URL; overrides `addr_env` when set |
98
+ | `addr_env` | `VAULT_ADDR` | Env var holding the Vault URL |
99
+ | `path` | `""` | KV v2 path to read (required) |
100
+ | `mount_point` | `secret` | KV v2 secrets-engine mount |
101
+ | `auth_method` | `token` | `token` or `approle` |
102
+ | `token_env` | `VAULT_TOKEN` | Env var with the token (token auth) |
103
+ | `role_id_env` | `VAULT_ROLE_ID`| Env var with the role_id (approle) |
104
+ | `secret_id_env` | `VAULT_SECRET_ID` | Env var with the secret_id (approle) |
105
+ | `namespace` | `""` | Vault Enterprise namespace |
106
+ | `verify` | `true` | TLS verify: `true`/`false` or a CA-bundle path |
107
+ | `cache_ttl_seconds` | `300` | Disk+memory cache TTL; `0` disables caching |
108
+ | `request_timeout_seconds` | `30` | Per-request Vault HTTP timeout |
109
+ | `override_existing` | `true` | Vault values overwrite existing env values |
110
+
111
+ ## How it behaves
112
+
113
+ * **Never raises.** A misconfigured or unreachable Vault produces a clean
114
+ `FetchResult` with an `ErrorKind` (`not_configured`, `auth_failed`,
115
+ `ref_invalid`, `network`, `timeout`, ...), never a startup crash.
116
+ * **Protects its own credentials.** The auth env vars (`VAULT_ADDR`,
117
+ `VAULT_TOKEN`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`) are marked protected, so a
118
+ secret stored in Vault cannot clobber the credential used to reach Vault.
119
+ * **Caches values, never tokens.** The optional on-disk cache stores only the
120
+ resolved secret values; the token/secret_id are SHA-256 fingerprinted before
121
+ they touch the cache key.
122
+ * **Skips non-env-safe keys.** A KV key that is not a valid environment
123
+ variable name is skipped with a warning rather than injected.
124
+
125
+ ## Threat model
126
+
127
+ This plugin runs on the Hermes startup path with access to Vault credentials, so
128
+ its security posture is deliberately narrow. Every protection below is already
129
+ implemented; each maps to a concrete mechanism in the source.
130
+
131
+ ### Startup cannot be crashed by this source
132
+
133
+ `fetch()` never raises. Every configuration problem returns a `FetchResult` with
134
+ a machine-readable `ErrorKind` (`not_configured`, `auth_failed`, `ref_invalid`,
135
+ `network`, `timeout`, `binary_missing`, `internal`), and the backend in
136
+ `_client.py` only ever raises `RuntimeError`, which `fetch()` catches and
137
+ classifies. A misconfigured, unreachable, or unauthenticated Vault degrades to a
138
+ clean skip, never a traceback on the non-interactive startup path.
139
+
140
+ ### A Vault secret cannot hijack the credential used to reach Vault
141
+
142
+ `protected_env_vars()` marks the auth env vars (`VAULT_ADDR`, `VAULT_TOKEN`,
143
+ `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, or their configured overrides) as protected.
144
+ The orchestrator will not overwrite a protected variable, so a key that happens
145
+ to be named `VAULT_TOKEN` inside the Vault payload cannot clobber the token the
146
+ process is already using to authenticate. Names that are not valid env-var
147
+ identifiers are dropped from the protected set, so a misconfigured section cannot
148
+ silently weaken it.
149
+
150
+ ### Tokens never reach the on-disk cache in the clear
151
+
152
+ The optional two-layer cache stores only resolved secret values. The Vault token
153
+ (or the AppRole `role_id` and `secret_id`) is SHA-256 fingerprinted by
154
+ `_fingerprint()` before it becomes part of the cache key, so neither the in-memory
155
+ key nor the `vault_cache.json` file on disk contains the raw credential. Caching
156
+ is opt-out: `cache_ttl_seconds: 0` disables it entirely.
157
+
158
+ ### Only environment-safe keys are injected
159
+
160
+ Each KV v2 key is validated with the host's `is_valid_env_name()` before it is
161
+ contributed. A key that is not a valid environment-variable name is skipped with
162
+ a warning instead of being injected, so a stray Vault key cannot produce a
163
+ malformed process environment.
164
+
165
+ ### TLS verification is on by default
166
+
167
+ `verify` defaults to `true`. It is disabled only when the operator explicitly
168
+ sets a false-y value, and a string value is treated as a CA-bundle path. TLS
169
+ therefore fails closed rather than open.
170
+
171
+ ## Development
172
+
173
+ Requires a `hermes-agent` source checkout (for the `agent.*` packages and the
174
+ conformance kit at `tests/secret_sources/conformance.py`).
175
+
176
+ ```bash
177
+ # host = hermes-agent checkout, plugin = this repo. Put the host on PYTHONPATH so
178
+ # `import tests` resolves to the host conformance package. This repo keeps its own
179
+ # tests in `checks/` (not `tests/`) precisely so it cannot shadow that package.
180
+ PYTHONPATH="/path/to/hermes-agent:$(pwd)" pytest
181
+ ```
182
+
183
+ `checks/test_conformance.py` runs the host `SecretSourceConformance` contract;
184
+ `checks/test_vault_source.py` covers behavior with `hvac` mocked (no network, no
185
+ Vault binary needed).
186
+
187
+ To run against a real dev server:
188
+
189
+ ```bash
190
+ vault server -dev # prints VAULT_ADDR + a root token
191
+ export VAULT_ADDR=http://127.0.0.1:8200
192
+ export VAULT_TOKEN=<root-token-from-output>
193
+ vault kv put secret/apps/my-agent API_KEY=abc DB_URL=postgres://x
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT
@@ -0,0 +1,170 @@
1
+ # hermes-vault-secret-source
2
+
3
+ [![CI](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml/badge.svg)](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml)
4
+
5
+ A [HashiCorp Vault](https://developer.hashicorp.com/vault) secret source for
6
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent), packaged as a
7
+ standalone pip plugin.
8
+
9
+ At Hermes startup it reads one Vault **KV v2** secret path and injects every
10
+ key found there as an environment variable, the same way the bundled Bitwarden
11
+ Secrets Manager source injects a whole project. This is a **bulk** source, so
12
+ explicit per-variable bindings from mapped sources (like 1Password) take
13
+ precedence over it.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install "hermes-vault-secret-source @ git+https://github.com/cryptoyasenka/hermes-vault-secret-source"
19
+ ```
20
+
21
+ The package is not on PyPI yet, so install it straight from the repository as
22
+ shown above. Once it is published, this simplifies to:
23
+
24
+ ```bash
25
+ pip install hermes-vault-secret-source
26
+ ```
27
+
28
+ Either way this pulls in [`hvac`](https://hvac.readthedocs.io/), the official
29
+ Python client for Vault. Install it into the same environment as `hermes-agent`.
30
+
31
+ Hermes discovers the plugin automatically through the `hermes_agent.plugins`
32
+ entry point, but plugins are opt-in. Enable it once:
33
+
34
+ ```bash
35
+ hermes plugins enable hermes-hashicorp-vault
36
+ ```
37
+
38
+ ## Configure
39
+
40
+ Add a `vault` block under `secrets` in your Hermes config:
41
+
42
+ ```yaml
43
+ secrets:
44
+ vault:
45
+ enabled: true
46
+ # addr is read from $VAULT_ADDR by default; set `addr` to hard-code it.
47
+ path: apps/my-agent # KV v2 path whose keys become env vars
48
+ mount_point: secret # KV v2 mount (default: secret)
49
+ auth_method: token # "token" (default) or "approle"
50
+ cache_ttl_seconds: 300 # 0 disables caching
51
+ ```
52
+
53
+ Credentials are never written into config; they come from environment
54
+ variables:
55
+
56
+ | Auth method | Env vars used |
57
+ |-------------|--------------------------------------------------|
58
+ | `token` | `VAULT_ADDR`, `VAULT_TOKEN` |
59
+ | `approle` | `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID` |
60
+
61
+ The env-var names are overridable per field (`addr_env`, `token_env`,
62
+ `role_id_env`, `secret_id_env`) if your deployment uses different names.
63
+
64
+ ### All config keys
65
+
66
+ | Key | Default | Meaning |
67
+ |---------------------------|----------------|-------------------------------------------------|
68
+ | `enabled` | `false` | Master switch |
69
+ | `addr` | `""` | Vault URL; overrides `addr_env` when set |
70
+ | `addr_env` | `VAULT_ADDR` | Env var holding the Vault URL |
71
+ | `path` | `""` | KV v2 path to read (required) |
72
+ | `mount_point` | `secret` | KV v2 secrets-engine mount |
73
+ | `auth_method` | `token` | `token` or `approle` |
74
+ | `token_env` | `VAULT_TOKEN` | Env var with the token (token auth) |
75
+ | `role_id_env` | `VAULT_ROLE_ID`| Env var with the role_id (approle) |
76
+ | `secret_id_env` | `VAULT_SECRET_ID` | Env var with the secret_id (approle) |
77
+ | `namespace` | `""` | Vault Enterprise namespace |
78
+ | `verify` | `true` | TLS verify: `true`/`false` or a CA-bundle path |
79
+ | `cache_ttl_seconds` | `300` | Disk+memory cache TTL; `0` disables caching |
80
+ | `request_timeout_seconds` | `30` | Per-request Vault HTTP timeout |
81
+ | `override_existing` | `true` | Vault values overwrite existing env values |
82
+
83
+ ## How it behaves
84
+
85
+ * **Never raises.** A misconfigured or unreachable Vault produces a clean
86
+ `FetchResult` with an `ErrorKind` (`not_configured`, `auth_failed`,
87
+ `ref_invalid`, `network`, `timeout`, ...), never a startup crash.
88
+ * **Protects its own credentials.** The auth env vars (`VAULT_ADDR`,
89
+ `VAULT_TOKEN`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`) are marked protected, so a
90
+ secret stored in Vault cannot clobber the credential used to reach Vault.
91
+ * **Caches values, never tokens.** The optional on-disk cache stores only the
92
+ resolved secret values; the token/secret_id are SHA-256 fingerprinted before
93
+ they touch the cache key.
94
+ * **Skips non-env-safe keys.** A KV key that is not a valid environment
95
+ variable name is skipped with a warning rather than injected.
96
+
97
+ ## Threat model
98
+
99
+ This plugin runs on the Hermes startup path with access to Vault credentials, so
100
+ its security posture is deliberately narrow. Every protection below is already
101
+ implemented; each maps to a concrete mechanism in the source.
102
+
103
+ ### Startup cannot be crashed by this source
104
+
105
+ `fetch()` never raises. Every configuration problem returns a `FetchResult` with
106
+ a machine-readable `ErrorKind` (`not_configured`, `auth_failed`, `ref_invalid`,
107
+ `network`, `timeout`, `binary_missing`, `internal`), and the backend in
108
+ `_client.py` only ever raises `RuntimeError`, which `fetch()` catches and
109
+ classifies. A misconfigured, unreachable, or unauthenticated Vault degrades to a
110
+ clean skip, never a traceback on the non-interactive startup path.
111
+
112
+ ### A Vault secret cannot hijack the credential used to reach Vault
113
+
114
+ `protected_env_vars()` marks the auth env vars (`VAULT_ADDR`, `VAULT_TOKEN`,
115
+ `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, or their configured overrides) as protected.
116
+ The orchestrator will not overwrite a protected variable, so a key that happens
117
+ to be named `VAULT_TOKEN` inside the Vault payload cannot clobber the token the
118
+ process is already using to authenticate. Names that are not valid env-var
119
+ identifiers are dropped from the protected set, so a misconfigured section cannot
120
+ silently weaken it.
121
+
122
+ ### Tokens never reach the on-disk cache in the clear
123
+
124
+ The optional two-layer cache stores only resolved secret values. The Vault token
125
+ (or the AppRole `role_id` and `secret_id`) is SHA-256 fingerprinted by
126
+ `_fingerprint()` before it becomes part of the cache key, so neither the in-memory
127
+ key nor the `vault_cache.json` file on disk contains the raw credential. Caching
128
+ is opt-out: `cache_ttl_seconds: 0` disables it entirely.
129
+
130
+ ### Only environment-safe keys are injected
131
+
132
+ Each KV v2 key is validated with the host's `is_valid_env_name()` before it is
133
+ contributed. A key that is not a valid environment-variable name is skipped with
134
+ a warning instead of being injected, so a stray Vault key cannot produce a
135
+ malformed process environment.
136
+
137
+ ### TLS verification is on by default
138
+
139
+ `verify` defaults to `true`. It is disabled only when the operator explicitly
140
+ sets a false-y value, and a string value is treated as a CA-bundle path. TLS
141
+ therefore fails closed rather than open.
142
+
143
+ ## Development
144
+
145
+ Requires a `hermes-agent` source checkout (for the `agent.*` packages and the
146
+ conformance kit at `tests/secret_sources/conformance.py`).
147
+
148
+ ```bash
149
+ # host = hermes-agent checkout, plugin = this repo. Put the host on PYTHONPATH so
150
+ # `import tests` resolves to the host conformance package. This repo keeps its own
151
+ # tests in `checks/` (not `tests/`) precisely so it cannot shadow that package.
152
+ PYTHONPATH="/path/to/hermes-agent:$(pwd)" pytest
153
+ ```
154
+
155
+ `checks/test_conformance.py` runs the host `SecretSourceConformance` contract;
156
+ `checks/test_vault_source.py` covers behavior with `hvac` mocked (no network, no
157
+ Vault binary needed).
158
+
159
+ To run against a real dev server:
160
+
161
+ ```bash
162
+ vault server -dev # prints VAULT_ADDR + a root token
163
+ export VAULT_ADDR=http://127.0.0.1:8200
164
+ export VAULT_TOKEN=<root-token-from-output>
165
+ vault kv put secret/apps/my-agent API_KEY=abc DB_URL=postgres://x
166
+ ```
167
+
168
+ ## License
169
+
170
+ MIT
@@ -0,0 +1,280 @@
1
+ """HashiCorp Vault as a Hermes Agent secret source.
2
+
3
+ Bulk KV v2 source: reads one Vault KV v2 secret path and contributes its
4
+ keys as environment variables at Hermes startup, mirroring how the bundled
5
+ Bitwarden Secrets Manager source injects a whole project.
6
+
7
+ Registered through the ``hermes_agent.plugins`` entry point (see
8
+ pyproject.toml): Hermes' plugin loader imports this package and calls
9
+ ``register(ctx)``.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+ from typing import FrozenSet
16
+
17
+ from agent.secret_sources.base import (
18
+ ErrorKind,
19
+ FetchResult,
20
+ SecretSource,
21
+ is_valid_env_name,
22
+ )
23
+
24
+ from . import _client
25
+
26
+ __all__ = ["VaultSource", "register"]
27
+
28
+ # The following are NAMES of environment variables that hold Vault credentials,
29
+ # not secret values themselves; bandit B105 flags the "TOKEN"/"SECRET" literals.
30
+ _DEFAULT_ADDR_ENV = "VAULT_ADDR"
31
+ _DEFAULT_TOKEN_ENV = "VAULT_TOKEN" # nosec B105
32
+ _DEFAULT_ROLE_ID_ENV = "VAULT_ROLE_ID"
33
+ _DEFAULT_SECRET_ID_ENV = "VAULT_SECRET_ID" # nosec B105
34
+ _DEFAULT_MOUNT_POINT = "secret"
35
+ _DEFAULT_CACHE_TTL_SECONDS = 300.0
36
+ _DEFAULT_REQUEST_TIMEOUT_SECONDS = 30.0
37
+
38
+
39
+ class VaultSource(SecretSource):
40
+ """HashiCorp Vault KV v2 as a registered secret source.
41
+
42
+ Thin adapter over :func:`_client.fetch_vault_secrets`. ``fetch()`` only
43
+ resolves values; precedence, override semantics, conflict warnings and
44
+ the actual ``os.environ`` writes belong to the orchestrator
45
+ (``agent.secret_sources.registry.apply_all``).
46
+
47
+ Vault is a **bulk** source: it injects every key found at the configured
48
+ KV v2 path, so explicit per-var bindings from mapped sources outrank it.
49
+ """
50
+
51
+ name = "vault"
52
+ label = "HashiCorp Vault"
53
+ shape = "bulk"
54
+ scheme = "vault"
55
+
56
+ def override_existing(self, cfg: dict) -> bool:
57
+ # Default True: the point of a central vault is rotation. If .env had
58
+ # the final say, rotating a key in Vault would not take effect until
59
+ # the stale .env line was also removed.
60
+ return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
61
+
62
+ def protected_env_vars(self, cfg: dict) -> FrozenSet[str]:
63
+ cfg = cfg if isinstance(cfg, dict) else {}
64
+ names = {
65
+ str(cfg.get("addr_env") or _DEFAULT_ADDR_ENV),
66
+ str(cfg.get("token_env") or _DEFAULT_TOKEN_ENV),
67
+ str(cfg.get("role_id_env") or _DEFAULT_ROLE_ID_ENV),
68
+ str(cfg.get("secret_id_env") or _DEFAULT_SECRET_ID_ENV),
69
+ }
70
+ # A vault that stores its own VAULT_TOKEN must never clobber the
71
+ # credential used to reach it. The orchestrator also requires valid
72
+ # env-var names, so drop anything a misconfigured section produced.
73
+ return frozenset(n for n in names if is_valid_env_name(n))
74
+
75
+ def config_schema(self) -> dict:
76
+ return {
77
+ "enabled": {"description": "Master switch", "default": False},
78
+ "addr": {
79
+ "description": "Vault server URL (overrides addr_env when set)",
80
+ "default": "",
81
+ },
82
+ "addr_env": {
83
+ "description": "Env var holding the Vault server URL",
84
+ "default": _DEFAULT_ADDR_ENV,
85
+ },
86
+ "path": {
87
+ "description": "KV v2 secret path whose keys become env vars",
88
+ "default": "",
89
+ },
90
+ "mount_point": {
91
+ "description": "KV v2 secrets-engine mount point",
92
+ "default": _DEFAULT_MOUNT_POINT,
93
+ },
94
+ "auth_method": {
95
+ "description": "How to authenticate: 'token' or 'approle'",
96
+ "default": "token",
97
+ },
98
+ "token_env": {
99
+ "description": "Env var holding the Vault token (auth_method=token)",
100
+ "default": _DEFAULT_TOKEN_ENV,
101
+ },
102
+ "role_id_env": {
103
+ "description": "Env var holding the AppRole role_id (auth_method=approle)",
104
+ "default": _DEFAULT_ROLE_ID_ENV,
105
+ },
106
+ "secret_id_env": {
107
+ "description": "Env var holding the AppRole secret_id (auth_method=approle)",
108
+ "default": _DEFAULT_SECRET_ID_ENV,
109
+ },
110
+ "namespace": {
111
+ "description": "Vault Enterprise namespace (empty for OSS)",
112
+ "default": "",
113
+ },
114
+ "verify": {
115
+ "description": "TLS verify: true/false or a CA-bundle path",
116
+ "default": True,
117
+ },
118
+ "cache_ttl_seconds": {
119
+ "description": "Disk+memory cache TTL; 0 disables caching",
120
+ "default": 300,
121
+ },
122
+ "request_timeout_seconds": {
123
+ "description": "Per-request Vault HTTP timeout in seconds",
124
+ "default": 30,
125
+ },
126
+ "override_existing": {
127
+ "description": "Vault values overwrite .env/shell values",
128
+ "default": True,
129
+ },
130
+ }
131
+
132
+ def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
133
+ cfg = cfg if isinstance(cfg, dict) else {}
134
+ result = FetchResult()
135
+
136
+ addr_env = str(cfg.get("addr_env") or _DEFAULT_ADDR_ENV)
137
+ addr = str(cfg.get("addr") or os.environ.get(addr_env, "") or "").strip()
138
+ if not addr:
139
+ result.error = (
140
+ "secrets.vault is enabled but no Vault address is set "
141
+ f"(set {addr_env} or secrets.vault.addr)."
142
+ )
143
+ result.error_kind = ErrorKind.NOT_CONFIGURED
144
+ return result
145
+
146
+ path = str(cfg.get("path") or "").strip()
147
+ if not path:
148
+ result.error = (
149
+ "secrets.vault.path is empty (the KV v2 path whose keys are "
150
+ "injected as environment variables)."
151
+ )
152
+ result.error_kind = ErrorKind.NOT_CONFIGURED
153
+ return result
154
+
155
+ auth_method = str(cfg.get("auth_method") or "token").strip().lower()
156
+ # Empty-string initialisation, not a hardcoded secret (bandit B105).
157
+ token = role_id = secret_id = "" # nosec B105
158
+ if auth_method == "token":
159
+ token_env = str(cfg.get("token_env") or _DEFAULT_TOKEN_ENV)
160
+ token = os.environ.get(token_env, "").strip()
161
+ if not token:
162
+ result.error = (
163
+ f"secrets.vault auth_method is 'token' but {token_env} is not set."
164
+ )
165
+ result.error_kind = ErrorKind.NOT_CONFIGURED
166
+ return result
167
+ elif auth_method == "approle":
168
+ role_id_env = str(cfg.get("role_id_env") or _DEFAULT_ROLE_ID_ENV)
169
+ secret_id_env = str(cfg.get("secret_id_env") or _DEFAULT_SECRET_ID_ENV)
170
+ role_id = os.environ.get(role_id_env, "").strip()
171
+ secret_id = os.environ.get(secret_id_env, "").strip()
172
+ if not role_id or not secret_id:
173
+ result.error = (
174
+ "secrets.vault auth_method is 'approle' but "
175
+ f"{role_id_env} and {secret_id_env} are not both set."
176
+ )
177
+ result.error_kind = ErrorKind.NOT_CONFIGURED
178
+ return result
179
+ else:
180
+ result.error = (
181
+ f"secrets.vault.auth_method {auth_method!r} is not supported "
182
+ "(use 'token' or 'approle')."
183
+ )
184
+ result.error_kind = ErrorKind.NOT_CONFIGURED
185
+ return result
186
+
187
+ mount_point = (
188
+ str(cfg.get("mount_point") or _DEFAULT_MOUNT_POINT).strip()
189
+ or _DEFAULT_MOUNT_POINT
190
+ )
191
+ namespace = str(cfg.get("namespace") or "").strip() or None
192
+ verify = _coerce_verify(cfg.get("verify", True))
193
+ ttl = _coerce_float(
194
+ cfg.get("cache_ttl_seconds", _DEFAULT_CACHE_TTL_SECONDS),
195
+ _DEFAULT_CACHE_TTL_SECONDS,
196
+ allow_zero=True,
197
+ )
198
+ timeout = _coerce_float(
199
+ cfg.get("request_timeout_seconds", _DEFAULT_REQUEST_TIMEOUT_SECONDS),
200
+ _DEFAULT_REQUEST_TIMEOUT_SECONDS,
201
+ allow_zero=False,
202
+ )
203
+
204
+ try:
205
+ secrets, warnings = _client.fetch_vault_secrets(
206
+ addr=addr,
207
+ path=path,
208
+ mount_point=mount_point,
209
+ namespace=namespace,
210
+ verify=verify,
211
+ timeout=timeout,
212
+ auth_method=auth_method,
213
+ token=token,
214
+ role_id=role_id,
215
+ secret_id=secret_id,
216
+ cache_ttl_seconds=ttl,
217
+ home_path=home_path,
218
+ )
219
+ except RuntimeError as exc:
220
+ result.error = str(exc)
221
+ result.error_kind = _classify_vault_error(str(exc))
222
+ return result
223
+
224
+ result.secrets = secrets
225
+ result.warnings.extend(warnings)
226
+ return result
227
+
228
+
229
+ def _coerce_float(value, default: float, *, allow_zero: bool) -> float:
230
+ try:
231
+ out = float(value)
232
+ except (TypeError, ValueError):
233
+ return default
234
+ if out < 0 or (out == 0 and not allow_zero):
235
+ return default
236
+ return out
237
+
238
+
239
+ def _coerce_verify(value):
240
+ if isinstance(value, bool):
241
+ return value
242
+ if isinstance(value, str):
243
+ low = value.strip().lower()
244
+ if low in ("false", "0", "no", "off"):
245
+ return False
246
+ if low in ("true", "1", "yes", "on"):
247
+ return True
248
+ return value.strip() # otherwise treat it as a CA-bundle path
249
+ return True
250
+
251
+
252
+ def _classify_vault_error(message: str) -> ErrorKind:
253
+ """Best-effort mapping of a backend failure onto the shared taxonomy."""
254
+ lowered = message.lower()
255
+ if "not installed" in lowered:
256
+ return ErrorKind.BINARY_MISSING
257
+ if "timed out" in lowered or "timeout" in lowered:
258
+ return ErrorKind.TIMEOUT
259
+ if any(tok in lowered for tok in (
260
+ "forbidden", "permission denied", "invalid request",
261
+ "credentials rejected", "401", "403",
262
+ )):
263
+ return ErrorKind.AUTH_FAILED
264
+ if any(tok in lowered for tok in ("invalidpath", "not found", "404")):
265
+ return ErrorKind.REF_INVALID
266
+ if any(tok in lowered for tok in (
267
+ "connection", "network", "refused", "unreachable",
268
+ "resolve", "dns", "failed to create",
269
+ )):
270
+ return ErrorKind.NETWORK
271
+ return ErrorKind.INTERNAL
272
+
273
+
274
+ def register(ctx) -> None:
275
+ """Entry point invoked by Hermes' plugin loader.
276
+
277
+ ``ctx`` is the PluginContext; it validates the instance and hands it to
278
+ the secret-source registry.
279
+ """
280
+ ctx.register_secret_source(VaultSource())
@@ -0,0 +1,211 @@
1
+ """HashiCorp Vault backend: fetch + two-layer cache (hvac, KV v2, bulk).
2
+
3
+ Mirrors the security posture of the bundled Bitwarden/1Password sources:
4
+ this module only *reads* secrets and raises ``RuntimeError`` on any failure.
5
+ The :class:`~hermes_vault_secret_source.VaultSource` adapter turns that into
6
+ a ``FetchResult`` so nothing ever escapes onto the Hermes startup path.
7
+
8
+ ``hvac`` is imported lazily inside :func:`_read_kv2` so that importing this
9
+ module (and running the conformance suite) never requires the dependency;
10
+ only an actually-configured fetch touches Vault.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import time
16
+ from pathlib import Path
17
+ from typing import Dict, List, Optional, Tuple
18
+
19
+ from agent.secret_sources.base import is_valid_env_name
20
+
21
+ # The shared on-disk cache substrate is re-exported by the host for backends.
22
+ # If a host ships without it we degrade to no caching rather than fail import.
23
+ try:
24
+ from agent.secret_sources._cache import CachedFetch, DiskCache
25
+ except Exception: # pragma: no cover - only on an incompatible host
26
+ CachedFetch = None # type: ignore[assignment,misc]
27
+ DiskCache = None # type: ignore[assignment,misc]
28
+
29
+
30
+ def _serialize_cache_key(key) -> str:
31
+ return "|".join(str(part) for part in key)
32
+
33
+
34
+ _CACHE: Dict[tuple, "CachedFetch"] = {}
35
+ _DISK_CACHE = (
36
+ DiskCache("vault_cache.json", key_serializer=_serialize_cache_key)
37
+ if DiskCache is not None
38
+ else None
39
+ )
40
+
41
+
42
+ def _fingerprint(value: str) -> str:
43
+ """SHA-256 prefix of auth material, used only inside cache keys.
44
+
45
+ The on-disk cache stores secret *values*; the token/secret_id must never
46
+ reach it in the clear, so it is fingerprinted before it enters the key.
47
+ """
48
+ return hashlib.sha256((value or "").encode("utf-8")).hexdigest()[:16]
49
+
50
+
51
+ def fetch_vault_secrets(
52
+ *,
53
+ addr: str,
54
+ path: str,
55
+ mount_point: str = "secret",
56
+ namespace: Optional[str] = None,
57
+ verify=True,
58
+ timeout: float = 30.0,
59
+ auth_method: str = "token",
60
+ token: str = "",
61
+ role_id: str = "",
62
+ secret_id: str = "",
63
+ cache_ttl_seconds: float = 300.0,
64
+ use_cache: bool = True,
65
+ home_path: Optional[Path] = None,
66
+ ) -> Tuple[Dict[str, str], List[str]]:
67
+ """Read one KV v2 path and return ``(env_name -> value, warnings)``.
68
+
69
+ Raises ``RuntimeError`` on any fatal condition (hvac missing, auth
70
+ failure, network error, bad path, unexpected shape). The caller in
71
+ ``__init__.py`` catches this and classifies it onto an ``ErrorKind``.
72
+ """
73
+ if auth_method == "approle":
74
+ auth_fp = _fingerprint("approle:" + role_id + ":" + secret_id)
75
+ else:
76
+ auth_fp = _fingerprint("token:" + token)
77
+ cache_key = (auth_fp, addr, mount_point, path, namespace or "")
78
+
79
+ if use_cache and CachedFetch is not None:
80
+ cached = _CACHE.get(cache_key)
81
+ if cached and cached.is_fresh(cache_ttl_seconds):
82
+ return dict(cached.secrets), []
83
+ if _DISK_CACHE is not None:
84
+ disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
85
+ if disk_cached is not None:
86
+ _CACHE[cache_key] = disk_cached
87
+ return dict(disk_cached.secrets), []
88
+
89
+ secrets, warnings = _read_kv2(
90
+ addr=addr,
91
+ path=path,
92
+ mount_point=mount_point,
93
+ namespace=namespace,
94
+ verify=verify,
95
+ timeout=timeout,
96
+ auth_method=auth_method,
97
+ token=token,
98
+ role_id=role_id,
99
+ secret_id=secret_id,
100
+ )
101
+
102
+ if use_cache and CachedFetch is not None:
103
+ entry = CachedFetch(secrets=secrets, fetched_at=time.time())
104
+ _CACHE[cache_key] = entry
105
+ if _DISK_CACHE is not None:
106
+ _DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
107
+ return secrets, warnings
108
+
109
+
110
+ def _read_kv2(
111
+ *,
112
+ addr,
113
+ path,
114
+ mount_point,
115
+ namespace,
116
+ verify,
117
+ timeout,
118
+ auth_method,
119
+ token,
120
+ role_id,
121
+ secret_id,
122
+ ) -> Tuple[Dict[str, str], List[str]]:
123
+ try:
124
+ import hvac
125
+ from hvac import exceptions as hvac_exceptions
126
+ except ImportError as exc:
127
+ raise RuntimeError(
128
+ "hvac is not installed. Run `pip install hvac` in the Hermes "
129
+ "environment to use the Vault secret source."
130
+ ) from exc
131
+
132
+ try:
133
+ client = hvac.Client(
134
+ url=addr, verify=verify, timeout=timeout, namespace=namespace,
135
+ )
136
+ except Exception as exc: # defensive: never let client init escape
137
+ raise RuntimeError(
138
+ _transport_message(f"failed to create Vault client for {addr}", exc)
139
+ ) from exc
140
+
141
+ # -- authenticate ---------------------------------------------------
142
+ try:
143
+ if auth_method == "approle":
144
+ client.auth.approle.login(role_id=role_id, secret_id=secret_id)
145
+ else:
146
+ client.token = token
147
+ authed = client.is_authenticated()
148
+ except hvac_exceptions.Forbidden as exc:
149
+ raise RuntimeError(f"Vault authentication forbidden: {exc}") from exc
150
+ except hvac_exceptions.InvalidRequest as exc:
151
+ raise RuntimeError(
152
+ f"Vault authentication failed (invalid request): {exc}"
153
+ ) from exc
154
+ except Exception as exc:
155
+ raise RuntimeError(_transport_message("Vault authentication", exc)) from exc
156
+ if not authed:
157
+ raise RuntimeError(
158
+ "Vault authentication failed: credentials rejected "
159
+ "(is_authenticated() returned False)."
160
+ )
161
+
162
+ # -- read the KV v2 path -------------------------------------------
163
+ try:
164
+ resp = client.secrets.kv.v2.read_secret_version(
165
+ path=path, mount_point=mount_point, raise_on_deleted_version=True,
166
+ )
167
+ except hvac_exceptions.InvalidPath as exc:
168
+ raise RuntimeError(
169
+ f"Vault path not found (invalidpath): {mount_point}/{path}: {exc}"
170
+ ) from exc
171
+ except hvac_exceptions.Forbidden as exc:
172
+ raise RuntimeError(
173
+ f"Vault read forbidden for {mount_point}/{path}: {exc}"
174
+ ) from exc
175
+ except Exception as exc:
176
+ raise RuntimeError(
177
+ _transport_message(f"Vault read for {mount_point}/{path}", exc)
178
+ ) from exc
179
+
180
+ data = None
181
+ if isinstance(resp, dict):
182
+ inner = resp.get("data")
183
+ if isinstance(inner, dict):
184
+ data = inner.get("data")
185
+ if not isinstance(data, dict):
186
+ raise RuntimeError(
187
+ "Vault returned an unexpected response shape (data.data missing)."
188
+ )
189
+
190
+ secrets: Dict[str, str] = {}
191
+ warnings: List[str] = []
192
+ for key, value in data.items():
193
+ if not isinstance(key, str) or value is None:
194
+ continue
195
+ str_value = value if isinstance(value, str) else str(value)
196
+ if not is_valid_env_name(key):
197
+ warnings.append(f"Skipping secret {key!r}: not a valid env-var name")
198
+ continue
199
+ secrets[key] = str_value
200
+ return secrets, warnings
201
+
202
+
203
+ def _transport_message(context: str, exc: Exception) -> str:
204
+ """Build a RuntimeError message that ``_classify_vault_error`` can bucket."""
205
+ name = type(exc).__name__.lower()
206
+ text = str(exc).lower()
207
+ if "timeout" in name or "timed out" in text:
208
+ return f"{context} timed out: {exc}"
209
+ if "connection" in name or "connection" in text or "refused" in text:
210
+ return f"{context} connection error: {exc}"
211
+ return f"{context} failed: {exc}"
@@ -0,0 +1,198 @@
1
+ Metadata-Version: 2.4
2
+ Name: hermes-vault-secret-source
3
+ Version: 0.1.0
4
+ Summary: HashiCorp Vault secret source for Hermes Agent (bulk KV v2).
5
+ Author: cryptoyasenka
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/cryptoyasenka/hermes-vault-secret-source
8
+ Project-URL: Repository, https://github.com/cryptoyasenka/hermes-vault-secret-source
9
+ Project-URL: Issues, https://github.com/cryptoyasenka/hermes-vault-secret-source/issues
10
+ Keywords: hermes,hermes-agent,vault,hashicorp,secrets,plugin
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Topic :: Security
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Classifier: Operating System :: OS Independent
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: hvac>=2.0
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=7; extra == "test"
27
+ Dynamic: license-file
28
+
29
+ # hermes-vault-secret-source
30
+
31
+ [![CI](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml/badge.svg)](https://github.com/cryptoyasenka/hermes-vault-secret-source/actions/workflows/ci.yml)
32
+
33
+ A [HashiCorp Vault](https://developer.hashicorp.com/vault) secret source for
34
+ [Hermes Agent](https://github.com/NousResearch/hermes-agent), packaged as a
35
+ standalone pip plugin.
36
+
37
+ At Hermes startup it reads one Vault **KV v2** secret path and injects every
38
+ key found there as an environment variable, the same way the bundled Bitwarden
39
+ Secrets Manager source injects a whole project. This is a **bulk** source, so
40
+ explicit per-variable bindings from mapped sources (like 1Password) take
41
+ precedence over it.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ pip install "hermes-vault-secret-source @ git+https://github.com/cryptoyasenka/hermes-vault-secret-source"
47
+ ```
48
+
49
+ The package is not on PyPI yet, so install it straight from the repository as
50
+ shown above. Once it is published, this simplifies to:
51
+
52
+ ```bash
53
+ pip install hermes-vault-secret-source
54
+ ```
55
+
56
+ Either way this pulls in [`hvac`](https://hvac.readthedocs.io/), the official
57
+ Python client for Vault. Install it into the same environment as `hermes-agent`.
58
+
59
+ Hermes discovers the plugin automatically through the `hermes_agent.plugins`
60
+ entry point, but plugins are opt-in. Enable it once:
61
+
62
+ ```bash
63
+ hermes plugins enable hermes-hashicorp-vault
64
+ ```
65
+
66
+ ## Configure
67
+
68
+ Add a `vault` block under `secrets` in your Hermes config:
69
+
70
+ ```yaml
71
+ secrets:
72
+ vault:
73
+ enabled: true
74
+ # addr is read from $VAULT_ADDR by default; set `addr` to hard-code it.
75
+ path: apps/my-agent # KV v2 path whose keys become env vars
76
+ mount_point: secret # KV v2 mount (default: secret)
77
+ auth_method: token # "token" (default) or "approle"
78
+ cache_ttl_seconds: 300 # 0 disables caching
79
+ ```
80
+
81
+ Credentials are never written into config; they come from environment
82
+ variables:
83
+
84
+ | Auth method | Env vars used |
85
+ |-------------|--------------------------------------------------|
86
+ | `token` | `VAULT_ADDR`, `VAULT_TOKEN` |
87
+ | `approle` | `VAULT_ADDR`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID` |
88
+
89
+ The env-var names are overridable per field (`addr_env`, `token_env`,
90
+ `role_id_env`, `secret_id_env`) if your deployment uses different names.
91
+
92
+ ### All config keys
93
+
94
+ | Key | Default | Meaning |
95
+ |---------------------------|----------------|-------------------------------------------------|
96
+ | `enabled` | `false` | Master switch |
97
+ | `addr` | `""` | Vault URL; overrides `addr_env` when set |
98
+ | `addr_env` | `VAULT_ADDR` | Env var holding the Vault URL |
99
+ | `path` | `""` | KV v2 path to read (required) |
100
+ | `mount_point` | `secret` | KV v2 secrets-engine mount |
101
+ | `auth_method` | `token` | `token` or `approle` |
102
+ | `token_env` | `VAULT_TOKEN` | Env var with the token (token auth) |
103
+ | `role_id_env` | `VAULT_ROLE_ID`| Env var with the role_id (approle) |
104
+ | `secret_id_env` | `VAULT_SECRET_ID` | Env var with the secret_id (approle) |
105
+ | `namespace` | `""` | Vault Enterprise namespace |
106
+ | `verify` | `true` | TLS verify: `true`/`false` or a CA-bundle path |
107
+ | `cache_ttl_seconds` | `300` | Disk+memory cache TTL; `0` disables caching |
108
+ | `request_timeout_seconds` | `30` | Per-request Vault HTTP timeout |
109
+ | `override_existing` | `true` | Vault values overwrite existing env values |
110
+
111
+ ## How it behaves
112
+
113
+ * **Never raises.** A misconfigured or unreachable Vault produces a clean
114
+ `FetchResult` with an `ErrorKind` (`not_configured`, `auth_failed`,
115
+ `ref_invalid`, `network`, `timeout`, ...), never a startup crash.
116
+ * **Protects its own credentials.** The auth env vars (`VAULT_ADDR`,
117
+ `VAULT_TOKEN`, `VAULT_ROLE_ID`, `VAULT_SECRET_ID`) are marked protected, so a
118
+ secret stored in Vault cannot clobber the credential used to reach Vault.
119
+ * **Caches values, never tokens.** The optional on-disk cache stores only the
120
+ resolved secret values; the token/secret_id are SHA-256 fingerprinted before
121
+ they touch the cache key.
122
+ * **Skips non-env-safe keys.** A KV key that is not a valid environment
123
+ variable name is skipped with a warning rather than injected.
124
+
125
+ ## Threat model
126
+
127
+ This plugin runs on the Hermes startup path with access to Vault credentials, so
128
+ its security posture is deliberately narrow. Every protection below is already
129
+ implemented; each maps to a concrete mechanism in the source.
130
+
131
+ ### Startup cannot be crashed by this source
132
+
133
+ `fetch()` never raises. Every configuration problem returns a `FetchResult` with
134
+ a machine-readable `ErrorKind` (`not_configured`, `auth_failed`, `ref_invalid`,
135
+ `network`, `timeout`, `binary_missing`, `internal`), and the backend in
136
+ `_client.py` only ever raises `RuntimeError`, which `fetch()` catches and
137
+ classifies. A misconfigured, unreachable, or unauthenticated Vault degrades to a
138
+ clean skip, never a traceback on the non-interactive startup path.
139
+
140
+ ### A Vault secret cannot hijack the credential used to reach Vault
141
+
142
+ `protected_env_vars()` marks the auth env vars (`VAULT_ADDR`, `VAULT_TOKEN`,
143
+ `VAULT_ROLE_ID`, `VAULT_SECRET_ID`, or their configured overrides) as protected.
144
+ The orchestrator will not overwrite a protected variable, so a key that happens
145
+ to be named `VAULT_TOKEN` inside the Vault payload cannot clobber the token the
146
+ process is already using to authenticate. Names that are not valid env-var
147
+ identifiers are dropped from the protected set, so a misconfigured section cannot
148
+ silently weaken it.
149
+
150
+ ### Tokens never reach the on-disk cache in the clear
151
+
152
+ The optional two-layer cache stores only resolved secret values. The Vault token
153
+ (or the AppRole `role_id` and `secret_id`) is SHA-256 fingerprinted by
154
+ `_fingerprint()` before it becomes part of the cache key, so neither the in-memory
155
+ key nor the `vault_cache.json` file on disk contains the raw credential. Caching
156
+ is opt-out: `cache_ttl_seconds: 0` disables it entirely.
157
+
158
+ ### Only environment-safe keys are injected
159
+
160
+ Each KV v2 key is validated with the host's `is_valid_env_name()` before it is
161
+ contributed. A key that is not a valid environment-variable name is skipped with
162
+ a warning instead of being injected, so a stray Vault key cannot produce a
163
+ malformed process environment.
164
+
165
+ ### TLS verification is on by default
166
+
167
+ `verify` defaults to `true`. It is disabled only when the operator explicitly
168
+ sets a false-y value, and a string value is treated as a CA-bundle path. TLS
169
+ therefore fails closed rather than open.
170
+
171
+ ## Development
172
+
173
+ Requires a `hermes-agent` source checkout (for the `agent.*` packages and the
174
+ conformance kit at `tests/secret_sources/conformance.py`).
175
+
176
+ ```bash
177
+ # host = hermes-agent checkout, plugin = this repo. Put the host on PYTHONPATH so
178
+ # `import tests` resolves to the host conformance package. This repo keeps its own
179
+ # tests in `checks/` (not `tests/`) precisely so it cannot shadow that package.
180
+ PYTHONPATH="/path/to/hermes-agent:$(pwd)" pytest
181
+ ```
182
+
183
+ `checks/test_conformance.py` runs the host `SecretSourceConformance` contract;
184
+ `checks/test_vault_source.py` covers behavior with `hvac` mocked (no network, no
185
+ Vault binary needed).
186
+
187
+ To run against a real dev server:
188
+
189
+ ```bash
190
+ vault server -dev # prints VAULT_ADDR + a root token
191
+ export VAULT_ADDR=http://127.0.0.1:8200
192
+ export VAULT_TOKEN=<root-token-from-output>
193
+ vault kv put secret/apps/my-agent API_KEY=abc DB_URL=postgres://x
194
+ ```
195
+
196
+ ## License
197
+
198
+ MIT
@@ -0,0 +1,11 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ hermes_vault_secret_source/__init__.py
5
+ hermes_vault_secret_source/_client.py
6
+ hermes_vault_secret_source.egg-info/PKG-INFO
7
+ hermes_vault_secret_source.egg-info/SOURCES.txt
8
+ hermes_vault_secret_source.egg-info/dependency_links.txt
9
+ hermes_vault_secret_source.egg-info/entry_points.txt
10
+ hermes_vault_secret_source.egg-info/requires.txt
11
+ hermes_vault_secret_source.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [hermes_agent.plugins]
2
+ hermes-hashicorp-vault = hermes_vault_secret_source
@@ -0,0 +1,4 @@
1
+ hvac>=2.0
2
+
3
+ [test]
4
+ pytest>=7
@@ -0,0 +1 @@
1
+ hermes_vault_secret_source
@@ -0,0 +1,63 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "hermes-vault-secret-source"
7
+ version = "0.1.0"
8
+ description = "HashiCorp Vault secret source for Hermes Agent (bulk KV v2)."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "cryptoyasenka" }]
14
+ keywords = ["hermes", "hermes-agent", "vault", "hashicorp", "secrets", "plugin"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Topic :: Security",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ "Operating System :: OS Independent",
26
+ ]
27
+ dependencies = ["hvac>=2.0"]
28
+
29
+ [project.optional-dependencies]
30
+ test = ["pytest>=7"]
31
+
32
+ [project.urls]
33
+ Homepage = "https://github.com/cryptoyasenka/hermes-vault-secret-source"
34
+ Repository = "https://github.com/cryptoyasenka/hermes-vault-secret-source"
35
+ Issues = "https://github.com/cryptoyasenka/hermes-vault-secret-source/issues"
36
+
37
+ # Hermes' plugin loader discovers standalone plugins through this entry-point
38
+ # group. The value points at the package module that exposes `register(ctx)`
39
+ # (loader does `getattr(module, "register")`), so it must be the module, not
40
+ # `module:register`.
41
+ [project.entry-points."hermes_agent.plugins"]
42
+ hermes-hashicorp-vault = "hermes_vault_secret_source"
43
+
44
+ [tool.setuptools]
45
+ packages = ["hermes_vault_secret_source"]
46
+
47
+ [tool.pytest.ini_options]
48
+ # importlib mode keeps this repo's `tests/` from shadowing the hermes-agent
49
+ # `tests` package (needed for tests.secret_sources.conformance).
50
+ addopts = "--import-mode=importlib"
51
+
52
+ [tool.mypy]
53
+ files = ["hermes_vault_secret_source"]
54
+ warn_redundant_casts = true
55
+ no_implicit_optional = true
56
+
57
+ # The host Hermes packages (agent.*) are resolved from a hermes-agent checkout on
58
+ # PYTHONPATH at runtime and in CI, and hvac ships no type stubs. Treat both as
59
+ # untyped external dependencies instead of type-checking their internals here.
60
+ [[tool.mypy.overrides]]
61
+ module = ["agent.*", "hvac", "hvac.*"]
62
+ ignore_missing_imports = true
63
+ follow_imports = "silent"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+