refactor(agent/creds): rebase bws/op/command sources onto the shared base+cache substrate

- bitwarden: drop dead apply_bitwarden_secrets (0 refs); encrypted cache uses
  atomic_write_json/entry_from_payload; fetch goes through SecretCache.lookup
  with an encrypted L2 reader; stale-fallback branches merged; _classify_bws_error
  is a rule table; token/override hooks come from the ABC.
- onepassword: same substrate; _missing_binary_error, _fingerprint, _guarded
  dedupe repeated text/logic; _classify_op_error is a rule table.
- command: drop dead parse_secret_output/get_command_secret/list_command_secrets/
  apply_command_secrets (0 refs outside own test); _log helper; tests repointed
  to _run_helper / CommandSource.fetch.
This commit is contained in:
Teknium
2026-09-02 10:07:21 -07:00
parent d4f2933262
commit 924e290741
4 changed files with 396 additions and 1231 deletions
File diff suppressed because it is too large Load Diff
+68 -319
View File
@@ -1,33 +1,25 @@
"""``command`` secret source — resolve secrets via a user-configured helper.
Ports the security semantics of the desktop app's TypeScript
``CommandSecretsProvider`` (hermes-desktop ``src/main/secrets/commandProvider.ts``)
to the Python agent. The helper command (e.g. ``keepassxc-cli``,
``secret-tool``, or a script that cats a tmpfs env file) comes from
``secrets.command`` in ``config.yaml`` — NEVER from ``.env``, which holds
only secret values.
``CommandSecretsProvider`` (``src/main/secrets/commandProvider.ts``). The
helper command (``keepassxc-cli``, ``secret-tool``, a script that cats a tmpfs
env file, ...) comes from ``secrets.command`` in ``config.yaml`` — NEVER from
``.env``, which holds only secret values.
Security model (mirrors the TS provider line-for-line where it matters):
Security model:
* The command string is the USER'S OWN configuration (same trust level as
the ``.env`` file they control), so it is run via ``/bin/sh -c <command>``.
* The requested key is passed to the child ONLY via the ``HERMES_SECRET_KEY``
environment variable — it is NEVER interpolated into the shell string, so
a hostile key name (e.g. ``"; rm -rf ~``) is inert data, not code.
* Hard timeout (default 3s) + output cap (default 1 MiB); any failure
(non-zero exit, timeout, spawn failure, oversized output) degrades to
"no value" rather than raising.
* Failures log ONLY structured fields (exit code / signal / errno) to
stderr — never the command string, the helper's stderr, or any secret
value. The helper's stderr is captured via a pipe and DISCARDED so its
diagnostics (which can carry secret material) never reach our stderr.
* The startup/apply path runs the helper exactly ONCE (with an empty
``HERMES_SECRET_KEY``) — it is never called per-key in a loop, so a
helper that blocks (e.g. on a vault unlock prompt) can't be spawned
dozens of times.
* PLATFORM: the provider is POSIX-only (needs ``/bin/sh``). On Windows it
degrades to an empty result with a warning; Windows users stay on the
default ``env`` provider.
* The command string is the USER'S OWN configuration (same trust level as the
``.env`` they control), so it runs via ``/bin/sh -c <command>``.
* The requested key reaches the child ONLY via ``HERMES_SECRET_KEY`` — never
interpolated into the shell string, so a hostile key name is inert data.
* Hard timeout (default 3s) + output cap (1 MiB); every failure (non-zero exit,
timeout, spawn failure, oversized output) degrades to "no value", never raises.
* Failure logs carry ONLY structured fields (exit code / signal / errno) — never
the command string, the helper's stderr (captured and DISCARDED, it can carry
secret material) or any value.
* Startup runs the helper exactly ONCE with an empty ``HERMES_SECRET_KEY`` —
never per key — so a helper blocked on a vault prompt isn't spawned N times.
* POSIX-only (needs ``/bin/sh``); on Windows it degrades to an empty result.
"""
from __future__ import annotations
@@ -41,33 +33,25 @@ import sys
from pathlib import Path
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.base import get_source_environment
from agent.secret_sources.bitwarden import FetchResult
from agent.secret_sources.base import (
ErrorKind,
FetchResult,
SecretSource,
coerce_float,
source_child_env,
)
__all__ = [
"FetchResult",
"apply_command_secrets",
"get_command_secret",
"list_command_secrets",
"parse_secret_output",
"unquote_dotenv_value",
]
# Hard cap so a hung helper can never wedge startup. Kept deliberately
# TIGHT (3s) — a configured helper MUST be fast and NON-INTERACTIVE
# (e.g. `keepassxc-cli` against an already-unlocked DB, `secret-tool
# lookup`, or `cat`-ing a tmpfs env file), NOT something that prompts
# for a touch/PIN.
# TIGHT on purpose: a helper MUST be fast and NON-INTERACTIVE (an already
# unlocked DB, `secret-tool lookup`, `cat` of a tmpfs file) — not a PIN prompt.
_COMMAND_TIMEOUT_SECONDS = 3.0
# Defensive cap on helper output (1 MiB) — a misbehaving command can't OOM us.
_MAX_OUTPUT_BYTES = 1024 * 1024
_MAX_OUTPUT_BYTES = 1024 * 1024 # a misbehaving helper can't OOM us
# A line is treated as a KEY=VALUE pair only when it matches an env-key
# shape before the '='. Anchored; `.` does not cross newlines, so a
# multi-line blob never matches as a single "env-shaped" value.
# Anchored; `.` does not cross newlines, so a multi-line blob never matches.
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
@@ -75,13 +59,15 @@ def _is_windows() -> bool:
return os.name == "nt" or platform.system() == "Windows"
def unquote_dotenv_value(raw: str) -> str:
"""Strip a single layer of matching surrounding quotes from a dotenv value.
def _log(message: str) -> None:
print(f"[secrets:command] {message}", file=sys.stderr)
Requires length >= 2 so a lone quote (``"``) is left intact rather than
collapsing to empty, and ``""``/``''`` correctly yield an empty string.
Shared by the single-key parser and the list path so both unquote
identically.
def unquote_dotenv_value(raw: str) -> str:
"""Strip one layer of matching surrounding quotes from a dotenv value.
Requires length >= 2 so a lone ``"`` stays intact while ``""``/``''``
correctly yield an empty string.
"""
t = raw.strip()
if len(t) >= 2 and (
@@ -92,73 +78,6 @@ def unquote_dotenv_value(raw: str) -> str:
return t
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
* a bare value (single secret): the whole trimmed stdout is the value.
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
``wanted_key``.
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
misroute guard and the base64-padding disambiguation.
"""
text = stdout.replace("\r\n", "\n")
lines = text.split("\n")
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
# is deterministic and never returns another key's value.
dotenv_lines = [
line
for line in (raw.strip() for raw in lines)
if line and not line.startswith("#") and _ENV_LINE.match(line)
]
for line in dotenv_lines:
m = _ENV_LINE.match(line)
assert m is not None # filtered above
if m.group(1) == wanted_key:
value = unquote_dotenv_value(m.group(2))
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
# value": it would otherwise flow into an Authorization header
# → guaranteed 401.
return value if value.strip() != "" else None
# 2. The output is a multi-key dotenv dump that does NOT contain the
# wanted key → None, rather than mis-returning an unrelated line as
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
# non-matching env-shaped line falls through to the bare-value
# branch, because a bare secret can itself match the KEY=VALUE shape
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
# misclassified as a dump.
if len(dotenv_lines) > 1:
return None
# 3. Otherwise treat the whole output as a single bare value (a per-key
# helper that printed just the secret). Trim first so whitespace-only
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
value = text.strip()
if value == "":
return None
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
# env-file`, or a grep that matched the wrong line) emitting
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
# OTHER key's value — into an Authorization header sent to the WANTED
# key's endpoint: cross-provider credential leakage, not just a 401.
# Disambiguation from a bare base64 secret: base64 padding only ever
# produces an env-shaped line whose "value" part is empty or all '='
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
# after a non-matching key means a misrouted dotenv entry → None.
env_shaped = _ENV_LINE.match(value)
if (
env_shaped
and env_shaped.group(1) != wanted_key
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
):
return None
return value
def _run_helper(
command: str,
secret_key: str,
@@ -167,32 +86,17 @@ def _run_helper(
) -> Optional[str]:
"""Run the helper via ``/bin/sh -c`` and return its stdout, or None.
The key is passed as DATA via ``HERMES_SECRET_KEY`` — never interpolated
into the command string. Both stdout and stderr are captured via pipes
(never inherited); stderr is discarded. Any failure logs structured
fields only and returns None — never raises.
The key travels as DATA in ``HERMES_SECRET_KEY``. stdout/stderr are piped
(never inherited); stderr is discarded. Any failure logs structured fields
only and returns None — never raises.
"""
if _is_windows():
print(
"[secrets:command] the 'command' provider is POSIX-only "
"(needs /bin/sh); resolving no value on Windows",
file=sys.stderr,
)
_log("the 'command' provider is POSIX-only (needs /bin/sh); resolving no value on Windows")
return None
# User-configured secret-helper command: runs with the user's full shell
# env by design (it may need any credential to resolve the secret).
source_env = get_source_environment()
if source_env is os.environ:
# Legacy single-profile startup intentionally preserves the existing
# helper contract, which may rely on the user's full environment.
from tools.environments.local import build_subprocess_env
env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False)
else:
# A multiplex profile must never inherit sibling secrets from the
# process-global environment. hydrate_profile_secret_sources seeds
# only global-safe values plus this profile's own .env.
env = dict(source_env)
# The helper legitimately gets the user's shell env (it may need any
# credential to resolve the secret) — but a multiplex profile only its own.
env = source_child_env()
env["HERMES_SECRET_KEY"] = secret_key
try:
@@ -205,20 +109,14 @@ def _run_helper(
start_new_session=True, # so the hard timeout can kill the whole group
)
except OSError as exc:
print(
f"[secrets:command] helper failed to spawn; resolving no value: "
f"errno={exc.errno}",
file=sys.stderr,
)
_log(f"helper failed to spawn; resolving no value: errno={exc.errno}")
return None
try:
stdout_bytes, _stderr_discarded = proc.communicate(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
# Hard timeout: kill the whole process group (a helper script may
# have forked children that would otherwise keep the pipe open).
# POSIX-only by construction: _run_helper early-returns on Windows
# before ever spawning, so this line can't execute there.
# Kill the whole group: a helper may have forked children that would
# otherwise keep the pipe open. POSIX-only by the early return above.
try:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok
except (ProcessLookupError, PermissionError, OSError):
@@ -227,16 +125,10 @@ def _run_helper(
proc.communicate(timeout=1.0)
except (subprocess.TimeoutExpired, ValueError, OSError):
pass
print(
f"[secrets:command] helper timed out after {timeout_seconds:g}s; "
f"resolving no value",
file=sys.stderr,
)
_log(f"helper timed out after {timeout_seconds:g}s; resolving no value")
return None
if proc.returncode != 0:
# Structured fields ONLY — never the command string or the helper's
# stderr (either can carry secret material).
if proc.returncode < 0:
try:
sig = _signal.Signals(-proc.returncode).name
@@ -245,176 +137,40 @@ def _run_helper(
code, signame = "?", sig
else:
code, signame = str(proc.returncode), "none"
print(
f"[secrets:command] helper failed; resolving no value: "
f"code={code} signal={signame}",
file=sys.stderr,
)
_log(f"helper failed; resolving no value: code={code} signal={signame}")
return None
if len(stdout_bytes) > max_output_bytes:
print(
f"[secrets:command] helper output exceeded the "
f"{max_output_bytes}-byte cap; resolving no value",
file=sys.stderr,
)
_log(f"helper output exceeded the {max_output_bytes}-byte cap; resolving no value")
return None
return stdout_bytes.decode("utf-8", errors="replace")
def _parse_dotenv_map(stdout: str) -> Dict[str, str]:
"""Parse a KEY=VALUE blob into a map (the list/enumerate path).
Mirrors the TS ``list()``: only env-shaped lines contribute; comments
and non-matching lines are skipped. A bare-value helper yields ``{}``
— per-key resolution via :func:`get_command_secret` still works.
"""
"""Parse a KEY=VALUE blob; comments and non-env-shaped lines are skipped."""
out: Dict[str, str] = {}
for raw in stdout.replace("\r\n", "\n").split("\n"):
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _ENV_LINE.match(line)
if not m:
continue
out[m.group(1)] = unquote_dotenv_value(m.group(2))
if m:
out[m.group(1)] = unquote_dotenv_value(m.group(2))
return out
def get_command_secret(
*,
command: str,
key: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Optional[str]:
"""Resolve a single secret by running the helper with the key in
``HERMES_SECRET_KEY``. Returns None on any failure — never raises."""
command = (command or "").strip()
if not command:
return None
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
if stdout is None:
return None
return parse_secret_output(stdout, key)
def list_command_secrets(
*,
command: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Dict[str, str]:
"""Enumerate secrets by running the helper ONCE with an empty key.
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
a bare-value helper returns ``{}``. Never raises.
"""
command = (command or "").strip()
if not command:
return {}
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
return {}
return _parse_dotenv_map(stdout)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_command_secrets(
*,
command: str,
override_existing: bool = False,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Run the helper once at startup and set its KEY=VALUE output on
``os.environ``.
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.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 source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
return result
# The list/enumerate path: run the helper exactly ONCE with an empty
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.warnings.append(
"helper command failed at startup; no secrets applied "
"(process env / .env values remain in effect)"
)
return result
secrets = _parse_dotenv_map(stdout)
result.secrets = secrets
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing applied at "
"startup (a bare-value helper still resolves single keys on demand)"
)
return result
for key, value in secrets.items():
if value.strip() == "":
# Whitespace-only placeholder entries are "no value" — applying
# them would flow into an Authorization header → guaranteed 401.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
# Process env / .env win — same precedence as bitwarden.
result.skipped.append(key)
continue
os.environ[key] = value
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.
"""User-configured helper command as a registered **bulk** 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::
Composes with the other sources through ``apply_all()``; there is
deliberately NO single-provider selector. 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"
@@ -445,36 +201,29 @@ class CommandSource(SecretSource):
command = str(cfg.get("command") or "").strip()
if not command:
result.error = (
return result.fail(
"secrets.command.enabled is true but secrets.command.command "
"is empty. Set the helper command in config.yaml."
"is empty. Set the helper command in config.yaml.",
ErrorKind.NOT_CONFIGURED,
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
if _is_windows():
result.error = (
return result.fail(
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
"skipping on Windows",
ErrorKind.NOT_CONFIGURED,
)
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
timeout = coerce_float(cfg.get("helper_timeout_seconds", _COMMAND_TIMEOUT_SECONDS),
_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 = (
if stdout is None: # _run_helper already logged structured fields
return result.fail(
"helper command failed (see structured fields above); "
"no secrets applied"
"no secrets applied",
ErrorKind.INTERNAL,
)
result.error_kind = ErrorKind.INTERNAL
return result
secrets = _parse_dotenv_map(stdout)
if not secrets:
+132 -328
View File
@@ -1,40 +1,16 @@
"""1Password (`op` CLI) secret source.
Resolve provider credentials from 1Password ``op://vault/item/field``
references at process startup so they don't have to live in plaintext in
``~/.hermes/.env``.
Users map env-var names to official ``op://vault/item/field`` references in
``secrets.onepassword.env``; after ``.env`` loads each reference is resolved
with one ``op read -- <reference>`` call. Authentication is whatever the
user's ``op`` CLI already uses (``OP_SERVICE_ACCOUNT_TOKEN`` for headless
boxes, ``OP_SESSION_*`` for interactive sessions) — Hermes never authenticates
on the user's behalf. Failures NEVER block startup.
Design summary
--------------
* Users map environment-variable names to official 1Password secret
references in ``secrets.onepassword.env``::
secrets:
onepassword:
enabled: true
env:
OPENAI_API_KEY: "op://Private/OpenAI/api key"
ANTHROPIC_API_KEY: "op://Private/Anthropic/credential"
* After ``.env`` loads, each reference is resolved with a single
``op read -- <reference>`` call and injected into ``os.environ`` (the
same point in startup as the Bitwarden source).
* Authentication is whatever the user's ``op`` CLI already uses — a
service-account token (``OP_SERVICE_ACCOUNT_TOKEN``) for headless boxes,
or a desktop/interactive session (``OP_SESSION_*``). Hermes never
authenticates on the user's behalf; it shells out to an already-trusted,
already-authenticated CLI.
* Failures NEVER block startup. A missing ``op`` binary, expired auth, a
bad reference, or a permission error each surface a one-line warning and
Hermes continues with whatever credentials ``.env`` already had.
The atomic-write / ``0600`` / TTL cache mechanics are shared with the other
backends via :mod:`agent.secret_sources._cache` — successful, complete pulls
are cached in-process and on disk under ``<hermes_home>/cache/op_cache.json``
so back-to-back short-lived ``hermes`` invocations don't re-shell ``op`` for
every reference. The disk file holds only resolved secret *values*; auth
material is fingerprinted, never stored.
Successful, complete pulls are cached in-process and under
``<hermes_home>/cache/op_cache.json`` (values only; auth material is
fingerprinted, never stored) so back-to-back ``hermes`` invocations don't
re-shell ``op`` for every reference.
"""
from __future__ import annotations
@@ -43,61 +19,38 @@ import hashlib
import logging
import os
import shutil
import subprocess
import subprocess # noqa: F401 — tests monkeypatch ``op.subprocess.run``
import time
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch,
DiskCache,
from agent.secret_sources._cache import CachedFetch, SecretCache
from agent.secret_sources.base import (
ErrorKind,
FetchResult,
SecretSource,
classify_cli_error,
coerce_float,
get_source_environment,
is_valid_env_name,
run_cli,
)
from agent.secret_sources.base import ErrorKind, SecretSource
from agent.secret_sources.base import get_source_environment
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Configuration constants
# ---------------------------------------------------------------------------
# How long to wait for a single `op read`, in seconds.
_OP_RUN_TIMEOUT = 30
# Default env var the official `op` CLI reads for service-account auth. Users
# can point `service_account_token_env` at a different name; we always export
# the value to the child as OP_SERVICE_ACCOUNT_TOKEN, which is what `op` itself
# looks for.
# `op` itself reads OP_SERVICE_ACCOUNT_TOKEN; `service_account_token_env` lets
# the user source it from another name, and _op_child_env normalizes it back.
_DEFAULT_TOKEN_ENV = "OP_SERVICE_ACCOUNT_TOKEN"
# ANSI stripping for `op` diagnostics we surface uses the shared
# tools.ansi_strip.strip_ansi (full ECMA-48: CSI, OSC, DCS/SOS/PM/APC,
# C1) so a control sequence can't reposition the cursor or hide text
# after a redaction marker.
# Env vars the `op` child actually needs. We build a minimal allowlisted env
# rather than copying all of os.environ (which, post-dotenv, holds every
# provider credential) into the child — tighter blast radius if `op` or
# anything it execs ever misbehaves. OP_SESSION_* and the token are added
# Minimal allowlisted child env (never the full post-dotenv os.environ, which
# holds every provider credential). OP_SESSION_* and the token are added
# dynamically in _op_child_env().
_OP_ENV_ALLOWLIST = (
"PATH",
"HOME",
"USERPROFILE",
"APPDATA",
"LOCALAPPDATA",
"SystemRoot",
"TMPDIR",
"TMP",
"TEMP",
"XDG_CONFIG_HOME",
"XDG_RUNTIME_DIR",
"OP_ACCOUNT",
"OP_CONNECT_HOST",
"OP_CONNECT_TOKEN",
"PATH", "HOME", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "SystemRoot",
"TMPDIR", "TMP", "TEMP", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR",
"OP_ACCOUNT", "OP_CONNECT_HOST", "OP_CONNECT_TOKEN",
# Lets a user skip op's desktop-app integration probe (which can hang with
# no timeout on a wedged desktop container) and go straight to token auth.
"OP_LOAD_DESKTOP_APP_SETTINGS",
@@ -108,35 +61,20 @@ _OP_ENV_ALLOWLIST = (
# Cache
# ---------------------------------------------------------------------------
# In-process cache. The key folds in str(home_path) so a HERMES_HOME switch
# inside one long-lived process (e.g. the gateway) can't return another
# profile's secrets from L1. The disk layer omits home from its serialized
# key because the file already lives under the home dir (see _disk_key_str).
# L1 key folds in str(home_path) so a HERMES_HOME switch inside one long-lived
# process (the gateway) can't return another profile's secrets. The disk key
# omits home because the file already lives under <home>/cache/.
_CacheKey = Tuple[str, str, str, str] # (auth_fp, account, home, refs_fp)
_CACHE: Dict[_CacheKey, CachedFetch] = {}
_DISK_CACHE_BASENAME = "op_cache.json"
def _disk_key_str(cache_key: _CacheKey) -> str:
"""Serialize a cache key for on-disk storage, omitting home_path.
The disk file is already partitioned by home (it lives under
``<home>/cache/``), so the path provides the home dimension; folding it
into the key string too would be redundant.
"""
auth_fp, account, _home, refs_fp = cache_key
return f"{auth_fp}|{account}|{refs_fp}"
_DISK_CACHE: DiskCache = DiskCache(
_DISK_CACHE_BASENAME, key_serializer=_disk_key_str
)
def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Path to the on-disk cache (exposed for tests and direct callers)."""
return _DISK_CACHE.path(home_path)
_STORE: SecretCache[_CacheKey] = SecretCache(_DISK_CACHE_BASENAME, key_serializer=_disk_key_str)
_CACHE = _STORE.memory # tests flush L1 directly
# ---------------------------------------------------------------------------
@@ -147,12 +85,7 @@ def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
def _validate_references(
references: Optional[Dict[str, str]],
) -> Tuple[Dict[str, str], List[str]]:
"""Return ``(valid_refs, warnings)`` from an ``env`` mapping.
A reference is kept only if its target env-var name is a valid POSIX
name and the value is a stripped ``op://…`` reference string. Everything
else produces a warning and is dropped (never fatal).
"""
"""``(valid_refs, warnings)``: keep valid env names bound to stripped ``op://`` strings."""
valid: Dict[str, str] = {}
warnings: List[str] = []
for name, ref in (references or {}).items():
@@ -172,16 +105,16 @@ def _validate_references(
return valid, warnings
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over the auth material `op` would use.
def _fingerprint(material: str) -> str:
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
Folds in the service-account token, ``OP_ACCOUNT``, the 1Password Connect
``OP_CONNECT_HOST``/``OP_CONNECT_TOKEN``, and *all* ``OP_SESSION_*`` vars
(the names `op` actually exports for interactive sessions —
``OP_SESSION_<account_shorthand>``). Signing out and into a different
identity therefore changes the cache key, so a value cached under a
previous identity is never served under a new one. Never logged or
displayed; the raw token never leaves this hash.
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over everything `op` would authenticate with.
Folds in the service-account token, OP_ACCOUNT, Connect host/token and all
``OP_SESSION_*`` vars, so signing into a different identity changes the
cache key and a value cached under the old identity is never served.
"""
source_env = get_source_environment()
parts: List[str] = [
@@ -193,28 +126,23 @@ def _auth_fingerprint(token_env: str) -> str:
for key in sorted(source_env):
if key.startswith("OP_SESSION_"):
parts.append(f"{key}={source_env[key]}")
material = "\n".join(parts)
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
return _fingerprint("\n".join(parts))
def _refs_fingerprint(references: Dict[str, str]) -> str:
"""SHA-256 prefix over the configured name→reference mapping."""
material = "\n".join(f"{name}={references[name]}" for name in sorted(references))
return hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
return _fingerprint("\n".join(f"{name}={references[name]}" for name in sorted(references)))
# ---------------------------------------------------------------------------
# Binary discovery
# Binary discovery + `op read`
# ---------------------------------------------------------------------------
def find_op(binary_path: str = "") -> Optional[Path]:
"""Resolve a usable ``op`` binary, or None.
When ``binary_path`` is set it is used verbatim and PATH is NOT consulted
— pinning an absolute path is a way to avoid trusting whatever ``op`` shows
up first on ``PATH``. A pinned-but-missing path returns None (the caller
surfaces a clear error) rather than silently falling back.
A pinned ``binary_path`` is used verbatim (PATH is NOT consulted) and a
pinned-but-missing path returns None rather than silently falling back.
"""
if binary_path:
pinned = Path(binary_path)
@@ -225,33 +153,17 @@ def find_op(binary_path: str = "") -> Optional[Path]:
return Path(found) if found else None
# ---------------------------------------------------------------------------
# `op read` invocation
# ---------------------------------------------------------------------------
def _scrub(text: str) -> str:
"""Remove ANSI control sequences and trim, for safe message surfacing."""
"""Full ECMA-48 ANSI strip (so a control sequence can't hide text after a redaction marker) + trim."""
from tools.ansi_strip import strip_ansi
# strip_ansi removes well-formed sequences; drop any stray lone ESC too.
return strip_ansi(text).replace("\x1b", "").strip()
def _op_child_env(token_value: str) -> Dict[str, str]:
"""Build a minimal allowlisted environment for the ``op`` child process."""
source_env = get_source_environment()
env: Dict[str, str] = {}
for key in _OP_ENV_ALLOWLIST:
val = source_env.get(key)
if val is not None:
env[key] = val
# Desktop / interactive session credentials.
for key, val in source_env.items():
if key.startswith("OP_SESSION_"):
env[key] = val
# `op` reads OP_SERVICE_ACCOUNT_TOKEN regardless of which env var the user
# configured Hermes to source it from, so normalize to that name here.
env = {k: source_env[k] for k in _OP_ENV_ALLOWLIST if k in source_env}
env.update((k, v) for k, v in source_env.items() if k.startswith("OP_SESSION_"))
if token_value:
env["OP_SERVICE_ACCOUNT_TOKEN"] = token_value
env["NO_COLOR"] = "1"
@@ -265,35 +177,21 @@ def _run_op_read(
account: str = "",
token_value: str = "",
) -> str:
"""Resolve a single ``op://`` reference to its value.
"""Resolve one ``op://`` reference; raises ``RuntimeError`` on any failure.
Raises :class:`RuntimeError` on any failure — including a ``returncode 0``
with empty output, which would otherwise silently clobber a good
``.env``/shell credential with ``""``.
An exit-0 empty/whitespace-only value is a failure too — applying it would
silently clobber a good .env/shell credential with ``""``.
"""
cmd: List[str] = [str(op), "read"]
if account:
cmd += ["--account", account]
# `--` terminates option parsing so a reference can never be mis-parsed as
# an `op` flag even if validation is ever loosened.
cmd += ["--", reference]
cmd += ["--", reference] # `--` so a reference can never parse as an op flag
try:
proc = subprocess.run( # noqa: S603 — op path is user-trusted, argv list
cmd,
env=_op_child_env(token_value),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_OP_RUN_TIMEOUT,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
f"op read timed out after {_OP_RUN_TIMEOUT}s for {reference!r}"
) from exc
except OSError as exc:
raise RuntimeError(f"failed to invoke op: {exc}") from exc
proc = run_cli(
cmd, env=_op_child_env(token_value), timeout=_OP_RUN_TIMEOUT, label="op",
timeout_message=f"op read timed out after {_OP_RUN_TIMEOUT}s for {reference!r}",
stdin=None,
)
if proc.returncode != 0:
err = _scrub(proc.stderr or "")[:200]
@@ -303,10 +201,7 @@ def _run_op_read(
f"op read exited {proc.returncode} for {reference!r}"
)
# `op` appends a trailing newline; strip only that so a value with
# intentional internal/edge spaces survives. But a value that is empty or
# whitespace-only is treated as empty: applying it would silently clobber a
# good .env/shell credential with effectively nothing.
# Strip only op's trailing newline so intentional edge spaces survive.
value = (proc.stdout or "").rstrip("\r\n")
if not value.strip():
raise RuntimeError(f"op read returned an empty value for {reference!r}")
@@ -331,13 +226,10 @@ def fetch_onepassword_secrets(
) -> Tuple[Dict[str, str], List[str]]:
"""Resolve ``references`` (name → ``op://…``) to ``(secrets, warnings)``.
Raises :class:`RuntimeError` only when no ``op`` binary is available — a
fatal "can't fetch anything" condition. Per-reference failures (expired
auth, bad reference, empty value) are collected as warnings and the
reference is dropped, so one bad entry never sinks the rest.
Only a complete, error-free pull is cached, so a transient auth failure
isn't frozen in for the whole TTL window.
Raises ``RuntimeError`` only when no ``op`` binary is available. Per-ref
failures become warnings and the ref is dropped, so one bad entry never
sinks the rest. Only a complete, error-free pull is cached, so a transient
auth failure isn't frozen in for the whole TTL window.
"""
valid, warnings = _validate_references(references)
if not valid:
@@ -352,14 +244,9 @@ def fetch_onepassword_secrets(
)
if use_cache:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
cached = _STORE.lookup(cache_key, cache_ttl_seconds, home_path)
if cached is not None:
return dict(cached.secrets), warnings
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into L1 so later fetches in this process skip the disk read.
_CACHE[cache_key] = disk_cached
return dict(disk_cached.secrets), warnings
op = binary or find_op(binary_path)
if op is None:
@@ -382,14 +269,27 @@ def fetch_onepassword_secrets(
if use_cache and not read_errors and secrets:
entry = CachedFetch(secrets=dict(secrets), fetched_at=time.time())
_CACHE[cache_key] = entry
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
_STORE.store(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
def _missing_binary_error(binary_path: str) -> str:
if binary_path:
return (
f"secrets.onepassword.binary_path ({binary_path!r}) is not an "
"executable op binary."
)
return (
"secrets.onepassword.enabled is true but the op CLI was not "
"found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) or set "
"secrets.onepassword.binary_path."
)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# Public entry point — used by `hermes secrets onepassword sync --apply`
# ---------------------------------------------------------------------------
@@ -406,13 +306,8 @@ def apply_onepassword_secrets(
) -> FetchResult:
"""Resolve configured ``op://`` references and set them on ``os.environ``.
Called by ``load_hermes_dotenv()`` after the .env files have loaded.
Intentionally defensive — any failure returns a :class:`FetchResult` with
``error`` set (or surfaces warnings); it never raises.
Parameters mirror the ``secrets.onepassword.*`` config keys so the caller
can splat the dict in. References that are already satisfied by the
current environment (when ``override_existing`` is false) are skipped
Never raises. References already satisfied by the environment (when
``override_existing`` is false) and the token var itself are skipped
*before* fetching, so ``op`` is never invoked for a value that would be
discarded.
"""
@@ -424,17 +319,18 @@ def apply_onepassword_secrets(
valid, warnings = _validate_references(env)
result.warnings.extend(warnings)
# Skip-before-fetch: never resolve a reference we'd only throw away.
def _guarded(name: str) -> bool:
"""True when ``name`` must not be applied (token var or env already set)."""
return name == service_account_token_env or (
not override_existing and bool(os.environ.get(name))
)
refs_to_fetch: Dict[str, str] = {}
for name, ref in valid.items():
if name == service_account_token_env:
# Never let a resolved secret clobber the very token used to auth.
if _guarded(name):
result.skipped.append(name)
continue
if not override_existing and os.environ.get(name):
result.skipped.append(name)
continue
refs_to_fetch[name] = ref
else:
refs_to_fetch[name] = ref
if not refs_to_fetch:
return result
@@ -442,18 +338,7 @@ def apply_onepassword_secrets(
binary = find_op(binary_path)
result.binary_path = binary
if binary is None:
if binary_path:
result.error = (
f"secrets.onepassword.binary_path ({binary_path!r}) is not an "
"executable op binary."
)
else:
result.error = (
"secrets.onepassword.enabled is true but the op CLI was not "
"found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) or set "
"secrets.onepassword.binary_path."
)
result.error = _missing_binary_error(binary_path)
return result
try:
@@ -473,13 +358,8 @@ def apply_onepassword_secrets(
result.warnings.extend(fetch_warnings)
for name, value in secrets.items():
# The token-var and override guards already filtered refs_to_fetch, but
# re-check defensively in case the fetch layer ever returns extras.
if name == service_account_token_env:
if name not in result.skipped:
result.skipped.append(name)
continue
if not override_existing and os.environ.get(name):
# Defensive re-check: keys should already be ⊆ refs_to_fetch.
if _guarded(name):
if name not in result.skipped:
result.skipped.append(name)
continue
@@ -495,65 +375,36 @@ def apply_onepassword_secrets(
class OnePasswordSource(SecretSource):
"""1Password as a registered secret source.
"""1Password as a registered **mapped** source.
Thin adapter over the module's fetch machinery. ``fetch()`` only
*fetches* — precedence, override semantics, conflict warnings, and
the ``os.environ`` writes are the orchestrator's job
(see ``agent.secret_sources.registry.apply_all``).
1Password is a **mapped** source: the user explicitly binds each env
var to an ``op://`` reference under ``secrets.onepassword.env``, so
its claims outrank bulk sources (e.g. a Bitwarden project dump) on
contested vars.
``fetch()`` only fetches — precedence, overrides and the ``os.environ``
writes are the orchestrator's. Mapped: the user explicitly binds each env
var to an ``op://`` ref, so its claims outrank bulk sources on contested vars.
"""
name = "onepassword"
label = "1Password"
shape = "mapped"
scheme = "op"
def override_existing(self, cfg: dict) -> bool:
# Default True: an explicit VAR→op:// binding is the strongest
# user intent there is — leaving a stale .env line in place
# should not silently defeat it (same rotation rationale as
# Bitwarden).
return bool(isinstance(cfg, dict) and cfg.get("override_existing", True))
def protected_env_vars(self, cfg: dict):
token_env = _DEFAULT_TOKEN_ENV
if isinstance(cfg, dict):
token_env = str(cfg.get("service_account_token_env") or token_env)
return frozenset({token_env})
token_env_key = "service_account_token_env"
default_token_env = _DEFAULT_TOKEN_ENV
# override_existing defaults True: an explicit VAR→op:// binding is the
# strongest user intent; a stale .env line must not silently defeat it.
override_existing_default = True
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"env": {
"description": "Map of ENV_VAR -> op://vault/item/field reference",
"default": {},
},
"account": {
"description": "op --account shorthand (empty = default account)",
"default": "",
},
"env": {"description": "Map of ENV_VAR -> op://vault/item/field reference", "default": {}},
"account": {"description": "op --account shorthand (empty = default account)", "default": ""},
"service_account_token_env": {
"description": "Env var holding the service-account token "
"(unset = desktop/interactive session)",
"default": _DEFAULT_TOKEN_ENV,
},
"binary_path": {
"description": "Pin the op binary (empty = resolve via PATH)",
"default": "",
},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"default": 300,
},
"override_existing": {
"description": "Resolved values overwrite .env/shell values",
"default": True,
},
"binary_path": {"description": "Pin the op binary (empty = resolve via PATH)", "default": ""},
"cache_ttl_seconds": {"description": "Disk+memory cache TTL; 0 disables", "default": 300},
"override_existing": {"description": "Resolved values overwrite .env/shell values", "default": True},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
@@ -567,52 +418,30 @@ class OnePasswordSource(SecretSource):
result.warnings.extend(warnings)
if not valid:
if not warnings:
result.error = (
result.fail(
"secrets.onepassword.enabled is true but the env: map is "
"empty. Add ENV_VAR: op://vault/item/field entries."
"empty. Add ENV_VAR: op://vault/item/field entries.",
ErrorKind.NOT_CONFIGURED,
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
binary_path = str(cfg.get("binary_path") or "")
binary = find_op(binary_path)
result.binary_path = binary
if binary is None:
if binary_path:
result.error = (
f"secrets.onepassword.binary_path ({binary_path!r}) is "
"not an executable op binary."
)
else:
result.error = (
"secrets.onepassword.enabled is true but the op CLI was "
"not found on PATH. Install it "
"(https://developer.1password.com/docs/cli/get-started/) "
"or set secrets.onepassword.binary_path."
)
result.error_kind = ErrorKind.BINARY_MISSING
return result
try:
ttl = float(cfg.get("cache_ttl_seconds", 300))
except (TypeError, ValueError):
ttl = 300.0
return result.fail(_missing_binary_error(binary_path), ErrorKind.BINARY_MISSING)
try:
secrets, fetch_warnings = fetch_onepassword_secrets(
references=valid,
account=str(cfg.get("account") or ""),
token_env=str(
cfg.get("service_account_token_env") or _DEFAULT_TOKEN_ENV
),
token_env=self.token_env(cfg),
binary=binary,
cache_ttl_seconds=ttl,
cache_ttl_seconds=coerce_float(cfg.get("cache_ttl_seconds", 300), 300.0),
home_path=home_path,
)
except RuntimeError as exc:
result.error = str(exc)
result.error_kind = _classify_op_error(str(exc))
return result
return result.fail(str(exc), _classify_op_error(str(exc)))
result.secrets = secrets
result.warnings.extend(fetch_warnings)
@@ -620,12 +449,9 @@ class OnePasswordSource(SecretSource):
def remediation(self, kind, cfg: dict) -> str:
if kind in (ErrorKind.AUTH_FAILED, ErrorKind.AUTH_EXPIRED):
token_env = _DEFAULT_TOKEN_ENV
if isinstance(cfg, dict):
token_env = str(cfg.get("service_account_token_env") or token_env)
return (
"Run `hermes secrets onepassword token` to paste a fresh "
f"service-account token ({token_env}), or `op signin` for an "
f"service-account token ({self.token_env(cfg)}), or `op signin` for an "
"interactive session."
)
if kind == ErrorKind.BINARY_MISSING:
@@ -637,46 +463,24 @@ class OnePasswordSource(SecretSource):
return super().remediation(kind, cfg)
_OP_ERROR_RULES = (
(ErrorKind.TIMEOUT, ("timed out",)),
(ErrorKind.BINARY_MISSING, ("not found on path", "not an executable", "failed to invoke")),
(ErrorKind.AUTH_FAILED, ("unauthorized", "not signed in", "session expired",
"authentication", "401", "403")),
(ErrorKind.EMPTY_VALUE, ("empty value",)),
(ErrorKind.NETWORK, ("network", "connection", "resolve host", "dns")),
)
def _classify_op_error(message: str) -> ErrorKind:
"""Best-effort mapping of op failure text onto the shared taxonomy."""
lowered = message.lower()
if "timed out" in lowered:
return ErrorKind.TIMEOUT
if "not found on path" in lowered or "not an executable" in lowered \
or "failed to invoke" in lowered:
return ErrorKind.BINARY_MISSING
if any(tok in lowered for tok in ("unauthorized", "not signed in",
"session expired", "authentication",
"401", "403")):
return ErrorKind.AUTH_FAILED
if "empty value" in lowered:
return ErrorKind.EMPTY_VALUE
if any(tok in lowered for tok in ("network", "connection", "resolve host",
"dns")):
return ErrorKind.NETWORK
return ErrorKind.INTERNAL
# ---------------------------------------------------------------------------
# Test hook — used by hermetic tests to flush the cache between cases.
# ---------------------------------------------------------------------------
return classify_cli_error(message, _OP_ERROR_RULES)
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches.
Used after a token rotation (`hermes secrets onepassword token`) so
the next startup resolves fresh with the new credential instead of
serving values cached under the old token's fingerprint.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
"""Drop in-process AND disk caches (after a token rotation, so the next
startup resolves fresh instead of serving values cached under the old token)."""
_STORE.clear(home_path)
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:
"""Clear in-process AND disk caches.
Tests can pass ``home_path`` to scope the disk cleanup to a tmpdir.
Without it we fall back to the same default resolution as the writer.
"""
clear_caches(home_path)
_reset_cache_for_tests = clear_caches
+8 -56
View File
@@ -9,9 +9,6 @@ Security invariants under test (ported from the desktop TS provider):
* the requested key travels ONLY via the ``HERMES_SECRET_KEY`` env var —
never interpolated into the shell string (hostile key names are inert);
* cross-key misroute guard: a single env-shaped line for a DIFFERENT key
never leaks as the wanted key's value;
* base64 '=' padding is not misclassified as a dotenv line;
* hard timeout + degrade-to-empty on every failure mode, never raise;
* failure logging carries structured fields only — never the command
string or any secret value.
@@ -35,11 +32,8 @@ if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from agent.secret_sources.command import ( # noqa: E402
CommandSource,
_run_helper,
apply_command_secrets,
get_command_secret,
list_command_secrets,
parse_secret_output,
unquote_dotenv_value,
)
from agent.secret_sources.base import ( # noqa: E402
@@ -106,54 +100,26 @@ def test_unquote_strips_one_layer_of_matching_quotes():
assert unquote_dotenv_value(" plain ") == "plain"
def test_parse_base64_padding_not_misclassified_as_dotenv():
# "dGVzdA==" looks env-shaped (key `dGVzdA`, value `=`) but is a bare
# base64 secret and must round-trip unchanged.
assert parse_secret_output("dGVzdA==\n", "CMDTEST_API_KEY") == "dGVzdA=="
# ---------------------------------------------------------------------------
# Real-subprocess resolution
# ---------------------------------------------------------------------------
def test_bare_value_helper_resolves_single_value(tmp_path):
def test_helper_stdout_is_returned_verbatim(tmp_path):
helper = _write_helper(tmp_path, "printf 'sk-test-bare-12345'")
value = get_command_secret(command=str(helper), key="CMDTEST_API_KEY")
value = _run_helper(str(helper), "CMDTEST_API_KEY", 3.0, 1024)
assert value == "sk-test-bare-12345"
def test_timeout_kills_hung_helper_and_degrades_to_empty(tmp_path):
helper = _write_helper(tmp_path, "sleep 30")
start = time.monotonic()
value = get_command_secret(
command=str(helper), key="CMDTEST_API_KEY", timeout_seconds=2.0
)
value = _run_helper(str(helper), "CMDTEST_API_KEY", 2.0, 1024)
elapsed = time.monotonic() - start
assert value is None
assert elapsed < 6.0, f"helper not killed within the bound (took {elapsed:.1f}s)"
def test_failure_logging_never_leaks_command_or_secret(tmp_path, capfd):
secret_value = "sk-super-secret-value-do-not-log"
helper = _write_helper(
@@ -161,7 +127,7 @@ def test_failure_logging_never_leaks_command_or_secret(tmp_path, capfd):
f"echo '{secret_value}' >&2\nexit 7",
name="my-distinctive-helper-name.sh",
)
value = get_command_secret(command=str(helper), key="CMDTEST_API_KEY")
value = _run_helper(str(helper), "CMDTEST_API_KEY", 3.0, 1024)
assert value is None
captured = capfd.readouterr()
combined = captured.out + captured.err
@@ -172,24 +138,14 @@ def test_failure_logging_never_leaks_command_or_secret(tmp_path, capfd):
assert "code=7" in combined # the structured field IS logged
def test_apply_dotenv_blob_sets_environ(tmp_path):
def test_fetch_parses_dotenv_blob(tmp_path):
helper = _write_helper(
tmp_path,
"printf 'CMDTEST_API_KEY=sk-applied\\nCMDTEST_TOKEN=tok-applied\\n'",
)
result = apply_command_secrets(command=str(helper))
assert sorted(result.applied) == ["CMDTEST_API_KEY", "CMDTEST_TOKEN"]
result = CommandSource().fetch({"enabled": True, "command": str(helper)}, tmp_path)
assert result.error is None
assert os.environ["CMDTEST_API_KEY"] == "sk-applied"
assert os.environ["CMDTEST_TOKEN"] == "tok-applied"
assert result.secrets == {"CMDTEST_API_KEY": "sk-applied", "CMDTEST_TOKEN": "tok-applied"}
# ---------------------------------------------------------------------------
@@ -245,8 +201,6 @@ def test_registry_status_line_printed_once_per_home(tmp_path, monkeypatch, capsy
assert err.count("Command helper: applied 1 secret") == 1
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(
@@ -258,8 +212,6 @@ def test_registry_failing_helper_does_not_block_startup(tmp_path, monkeypatch):
assert env_loader.get_secret_source("CMDTEST_API_KEY") is None
def test_registry_helper_error_prints_remediation(tmp_path, monkeypatch, capsys):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
(tmp_path / "config.yaml").write_text(