fix(secrets): move secret caches and the Photon token to the profile home root

The Bitwarden and 1Password disk caches (bws_cache.json, bws_cache.enc.json,
op_cache.json) and the Photon sidecar runtime record (photon-sidecar.json) now
live directly in the profile home instead of cache/ and runtime/. A sandbox
that hides credential files by path can only hide them safely at the top of
the home: the agent can rename a folder it writes, such as cache/ or runtime/,
and read a hidden file below it at the new path.

The first read moves a copy from the old path with a rename, so the file keeps
its permissions. When both copies exist, Hermes removes the old one; when the
move fails, Hermes keeps reading and writing the old copy, so only one copy is
ever live. Clearing a cache or deleting the record removes both locations.

SECRET_CACHE_STORES in hermes_cli/home_data_layout.py lists these stores at
both locations, and every guard that keeps a list of credential stores uses
it: PROFILE_CREDENTIAL_PATHS (profile export and distribution), the file
tools' read and write denies, and the gateway's media-delivery deny. The
1Password cache was missing from all of them. The dashboard Files API blocks
the new basenames, backups leave the top-level stores out, and the flat-install
.gitignore ignores them.

A new test takes each store's path from its writer and checks that every guard
covers it at the current and the old location.
This commit is contained in:
emozilla committed 2026-10-29 22:21:46 -04:00
1 parent 088f657ecf
commit 2bed5b2258
22 files changed
+491 -51

No files matched your search

+4
View File
@@ -243,6 +243,10 @@ docs/superpowers/*
/google_token.json
/google_oauth_pending.json
/webhook_subscriptions.json
/bws_cache.json
/bws_cache.enc.json
/op_cache.json
/photon-sidecar.json
/channel_directory.json
/channel_aliases.json
/feishu_comment_pairing.json
+5 -5
View File
@@ -12,6 +12,8 @@ from pathlib import Path
from contextlib import suppress
from typing import Optional
from hermes_cli.home_data_layout import SECRET_CACHE_STORES
def _constants_path(getter_name: str) -> Path:
"""Call ``hermes_constants.<getter_name>()`` (local import avoids cycles); ``~/.hermes`` on any failure."""
@@ -186,7 +188,7 @@ def build_write_denied_paths(home: str) -> set[str]:
# root: overwriting the root .env leaks credentials across every profile that
# inherits it, and the root Anthropic PKCE store is still read by default /
# non-profile sessions when a profile is active. google_oauth.json is an OAuth
# token store; both Bitwarden caches hold Secrets Manager material.
# token store; SECRET_CACHE_STORES hold secret values and the Photon sidecar token.
#
# auth.json, auth.lock, config.yaml and webhook_subscriptions.json are
# deliberately NOT here: #45947 freed those control files on purpose
@@ -195,8 +197,7 @@ def build_write_denied_paths(home: str) -> set[str]:
hermes_files = (
".env", ".anthropic_oauth.json",
os.path.join("auth", "google_oauth.json"),
os.path.join("cache", "bws_cache.json"),
os.path.join("cache", "bws_cache.enc.json"),
*SECRET_CACHE_STORES,
)
paths = [
*(os.path.join(home, *f) for f in home_files),
@@ -344,10 +345,9 @@ _DID_SUFFIX = (
# Exact-file credential stores under HERMES_HOME / <root>. The agent never
# needs these directly — provider tools consume them through internal channels.
# bws_cache.json is the Bitwarden Secrets Manager disk cache: plaintext secret values.
_CREDENTIAL_FILE_NAMES = (
"auth.json", "auth.lock", ".anthropic_oauth.json", ".env", "webhook_subscriptions.json",
os.path.join("auth", "google_oauth.json"), os.path.join("cache", "bws_cache.json"),
os.path.join("auth", "google_oauth.json"), *SECRET_CACHE_STORES,
)
# Directory-prefix read denies under HERMES_HOME / <root>: (subdir, message for
+29 -9
View File
@@ -17,7 +17,7 @@ from pathlib import Path
from typing import Callable, Dict, Generic, Optional, TypeVar
from hermes_constants import secure_parent_dir
from utils import atomic_json_write
from utils import adopt_legacy_file, atomic_json_write
__all__ = [
"CachedFetch",
@@ -26,6 +26,8 @@ __all__ = [
"atomic_write_json",
"entry_from_payload",
"fingerprint",
"live_cache_path",
"remove_cache_file",
"resolve_cache_home",
]
@@ -55,6 +57,21 @@ def resolve_cache_home(home_path: Optional[Path] = None) -> Path:
return home_path
def live_cache_path(home: Path, name: str) -> Path:
"""``<home>/<name>``, after moving a copy from ``<home>/cache/``, where earlier releases kept
the secret caches; that old path when the move fails."""
return adopt_legacy_file(home / name, home / "cache" / name)
def remove_cache_file(home: Path, name: str) -> None:
"""Delete ``<home>/<name>`` and an old ``<home>/cache/<name>`` copy if present (idempotent)."""
for path in (home / name, home / "cache" / name):
try:
path.unlink()
except OSError:
pass
def entry_from_payload(payload: object) -> Optional[CachedFetch]:
"""``{"secrets": {...}, "fetched_at": n}`` → :class:`CachedFetch`, or None if malformed.
@@ -86,10 +103,13 @@ K = TypeVar("K")
class DiskCache(Generic[K]):
"""Best-effort, profile-aware on-disk cache for fetched secret values.
One JSON object per backend at ``<hermes_home>/cache/<basename>``::
One JSON object per backend at ``<hermes_home>/<basename>``::
{"key": "<serialized cache key>", "secrets": {...}, "fetched_at": 1.0}
The file sits at the top of the home, where a sandbox can hide it by path: a folder the
agent writes (``cache/``) can be renamed to expose a hidden file below it.
The file holds only secret *values*, never raw auth material — backends
fingerprint tokens/sessions before they reach ``key_serializer``. Both
``read`` and ``write`` short-circuit when ``ttl_seconds <= 0``, so a TTL of
@@ -102,14 +122,17 @@ class DiskCache(Generic[K]):
self._key_serializer = key_serializer
def path(self, home_path: Optional[Path] = None) -> Path:
return resolve_cache_home(home_path) / "cache" / self._basename
return resolve_cache_home(home_path) / self._basename
def _live_path(self, home_path: Optional[Path]) -> Path:
return live_cache_path(resolve_cache_home(home_path), self._basename)
def read(self, key: K, ttl_seconds: float, home_path: Optional[Path] = None) -> Optional[CachedFetch]:
"""Fresh cached entry for ``key``, or None (I/O error, mismatch, stale)."""
if ttl_seconds <= 0:
return None
try:
with open(self.path(home_path), "r", encoding="utf-8-sig") as f:
with open(self._live_path(home_path), "r", encoding="utf-8-sig") as f:
payload = json.load(f)
except (OSError, json.JSONDecodeError):
return None
@@ -124,16 +147,13 @@ class DiskCache(Generic[K]):
return
payload = {"key": self._key_serializer(key), "secrets": entry.secrets, "fetched_at": entry.fetched_at}
try:
atomic_write_json(self.path(home_path), payload)
atomic_write_json(self._live_path(home_path), payload)
except OSError:
pass # best-effort — a disk-cache miss next invocation is fine
def clear(self, home_path: Optional[Path] = None) -> None:
"""Delete the on-disk cache file if present (idempotent)."""
try:
self.path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
remove_cache_file(resolve_cache_home(home_path), self._basename)
class SecretCache(Generic[K]):
+10 -9
View File
@@ -23,7 +23,7 @@ from typing import Dict, List, Optional, Tuple
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch, SecretCache, atomic_write_json, entry_from_payload,
fingerprint as _token_fingerprint, resolve_cache_home,
fingerprint as _token_fingerprint, live_cache_path, remove_cache_file, resolve_cache_home,
)
from agent.secret_sources.base import (
ErrorKind, FetchResult, SecretSource, classify_cli_error, coerce_float,
@@ -34,7 +34,7 @@ logger = logging.getLogger(__name__)
_BWS_RUN_TIMEOUT = 30
# <hermes_home>/cache/bws_cache.json holds only secret VALUES (never the access
# <hermes_home>/bws_cache.json holds only secret VALUES (never the access
# token); kept out of .env so users editing .env don't commit BSM-sourced secrets.
_CacheKey = tuple[str, str, str] # (access_token_fingerprint, project_id, server_url)
_DISK_CACHE_BASENAME = "bws_cache.json"
@@ -55,7 +55,11 @@ _disk_cache_path = _DISK_CACHE.path
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
return resolve_cache_home(home_path) / _ENCRYPTED_CACHE_BASENAME
def _live_encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
return live_cache_path(resolve_cache_home(home_path), _ENCRYPTED_CACHE_BASENAME)
# First matching rule wins. The BSM identity endpoint rejects a revoked /
@@ -139,7 +143,7 @@ def _write_encrypted_disk_cache(*, cache_key: _CacheKey, access_token: str, entr
ciphertext = AESGCM(key).encrypt(nonce, plaintext, serialized_key.encode("utf-8"))
payload = {"version": _ENCRYPTED_CACHE_VERSION, "key": serialized_key,
"salt": _b64e(salt), "nonce": _b64e(nonce), "ciphertext": _b64e(ciphertext)}
atomic_write_json(_encrypted_disk_cache_path(home_path), payload)
atomic_write_json(_live_encrypted_disk_cache_path(home_path), payload)
_STORE.disk.clear(home_path)
except Exception:
return
@@ -153,7 +157,7 @@ def _read_encrypted_disk_cache(*, cache_key: _CacheKey, access_token: str, max_a
try:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
payload = json.loads(_encrypted_disk_cache_path(home_path).read_text(encoding="utf-8-sig"))
payload = json.loads(_live_encrypted_disk_cache_path(home_path).read_text(encoding="utf-8-sig"))
serialized_key = _cache_key_str(cache_key)
if (not isinstance(payload, dict)
or payload.get("version") != _ENCRYPTED_CACHE_VERSION
@@ -390,10 +394,7 @@ class BitwardenSource(SecretSource):
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches (plaintext and encrypted), e.g. after a token rotation."""
_STORE.clear(home_path)
try:
_encrypted_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
remove_cache_file(resolve_cache_home(home_path), _ENCRYPTED_CACHE_BASENAME)
_reset_cache_for_tests = clear_caches
+2 -2
View File
@@ -5,7 +5,7 @@ Users map env-var names to ``op://vault/item/field`` references in
call using whatever auth the user's ``op`` already has (``OP_SERVICE_ACCOUNT_TOKEN``
headless, ``OP_SESSION_*`` interactive) — Hermes never authenticates on the
user's behalf, and failures never block startup. Complete pulls are cached
in-process and under ``<hermes_home>/cache/op_cache.json`` (values only; auth
in-process and under ``<hermes_home>/op_cache.json`` (values only; auth
material is fingerprinted, never stored).
"""
@@ -47,7 +47,7 @@ _OP_ENV_ALLOWLIST = (
# 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/.
# omits home because the file already lives in that home.
_CacheKey = tuple[str, str, str, str] # (auth_fp, account, home, refs_fp)
_DISK_CACHE_BASENAME = "op_cache.json"
+2 -2
View File
@@ -440,6 +440,7 @@ from gateway.platforms.base_streaming_tts import (
from gateway.platforms.event import MessageEvent, MessageType, ProcessingOutcome
from gateway.warning_notifications import diagnostic_wake_muted
from hermes_cli.observability.shared_metrics_gateway import records_delivery, stop_reply_clock
from hermes_cli.home_data_layout import SECRET_CACHE_STORES
from gateway.session import SessionSource, build_session_key
from gateway.session_transcript import TranscriptReadError
from hermes_constants import CONNECTOR_DOWNLOADS_SUBDIR, get_default_hermes_root, get_hermes_dir, get_hermes_home
@@ -782,8 +783,7 @@ def _sqlite_files(name: str) -> tuple[str, ...]:
_ROOT_CREDENTIAL_PATHS = (
".env", "auth.json", "auth.lock", "credentials", "config.yaml", ".anthropic_oauth.json",
"google_token.json", "google_oauth_pending.json", os.path.join("auth", "google_oauth.json"),
"webhook_subscriptions.json", os.path.join("cache", "bws_cache.json"),
os.path.join("cache", "bws_cache.enc.json"), "pairing", "mcp-tokens", "webapp",
"webhook_subscriptions.json", *SECRET_CACHE_STORES, "pairing", "mcp-tokens", "webapp",
# Whole conversation history (every secret ever pasted into a chat) and the copied browser
# cookie/login store; sessions/ is the legacy transcript dir. SQLite sidecars are listed
# too: WAL mode touches state.db-wal on every write, so recency trust alone would leak them.
+5 -5
View File
@@ -28,7 +28,7 @@ from agent.provider_media import GENERATED_SUBDIR
from hermes_cli.archive_safe import normalize_archive_parts
from hermes_cli.backup_sqlite import _close_quietly, _safe_copy_db
from hermes_cli.config_backend import require_file_tooling, supports_file_tooling
from hermes_cli.home_data_layout import PM_RUNTIME_ROOT_DIRS, profile_root_entry
from hermes_cli.home_data_layout import PM_RUNTIME_ROOT_DIRS, SECRET_CACHE_STORES, profile_root_entry
from hermes_cli.sizefmt import format_bytes as _format_size
from hermes_cli.backup_restore import (
@@ -98,10 +98,10 @@ _EXCLUDED_DIRS = {
# a deeper directory of the same name (such as a skill's models/) is user data.
_EXCLUDED_ROOT_DIRS = LOCAL_RUNTIME_ROOT_DIRS | (PM_RUNTIME_ROOT_DIRS - {"cache"})
# Browser Use CLI profile dir (browser.backend: browser-use): Chromium user-data with Login Data
# / Cookies. Root-scoped like models/ — a skill's own browser_profiles/ is user data. Backup-only:
# do not fold into LOCAL_RUNTIME_ROOT_DIRS (clone-all identity contract).
_EXCLUDED_BACKUP_ROOT_DIRS = frozenset({"browser_profiles"})
# Browser Use CLI profile dir (browser.backend: browser-use): Chromium user-data with Login Data / Cookies;
# the secret caches and Photon sidecar token. Root-scoped like models/ — a skill's own browser_profiles/ is
# user data. Backup-only: do not fold into LOCAL_RUNTIME_ROOT_DIRS (clone-all identity contract).
_EXCLUDED_BACKUP_ROOT_DIRS = frozenset({"browser_profiles", *SECRET_CACHE_STORES})
# ``cache/`` at those same roots mixes regenerable state (model/plugin catalogs, stamps, browser
# profiles with locked SQLite, tool-output spill) with durable artifacts nothing can rebuild: media
+9
View File
@@ -5,6 +5,15 @@ from __future__ import annotations
# Dependency generations, downloaded tools, and partial transfers belong to this machine.
PM_RUNTIME_ROOT_DIRS = frozenset({"installs", "tools", "cache"})
# Bitwarden / 1Password secret-value caches and the Photon sidecar auth token, as POSIX paths relative to
# a home: at its top, where a sandbox can hide a file by path (a writable folder above a hidden file can be
# renamed), and at the cache/ and runtime/ paths earlier releases used, which the first read moves up.
# Credential stores for every guard that lists them; regenerated on use, so backups leave them out.
SECRET_CACHE_STORES = (
"bws_cache.json", "bws_cache.enc.json", "op_cache.json", "photon-sidecar.json",
"cache/bws_cache.json", "cache/bws_cache.enc.json", "cache/op_cache.json", "runtime/photon-sidecar.json",
)
def profile_root_entry(parts: tuple[str, ...]) -> str | None:
"""Locate a home-root entry without excluding same-named skill/plugin data."""
+2 -3
View File
@@ -20,7 +20,7 @@ from agent.file_safety import HOME_CREDENTIAL_DIRS
from hermes_cli.archive_safe import archive_root_dirs, make_targz, normalize_archive_parts, safe_extract_targz
from hermes_cli import config_backend as _config_backend
from hermes_cli.profiles_config import _migrate_profile_config_if_outdated
from hermes_cli.home_data_layout import PM_RUNTIME_ROOT_DIRS
from hermes_cli.home_data_layout import PM_RUNTIME_ROOT_DIRS, SECRET_CACHE_STORES
from hermes_cli.profiles_remove import _rmtree_make_writable, _rmtree_with_retry, wait_profile_unowned
from hermes_constants import (
LOCAL_RUNTIME_ROOT_DIRS, PROFILE_ID_RE, clear_named_profile_deleted, mark_named_profile_deleted,
@@ -175,11 +175,10 @@ PROFILE_CREDENTIAL_PATHS = frozenset({
"browser-profiles", "browser_profiles", # live CDP profiles, Browser Use CLI dir (Cookies, Login Data)
"pairing", "platforms/pairing", "feishu_comment_pairing.json",
"whatsapp/session", "platforms/whatsapp/session", "matrix/store", "platforms/matrix/store",
"cache/bws_cache.json", "cache/bws_cache.enc.json",
*SECRET_CACHE_STORES, # Bitwarden / 1Password value caches, Photon sidecar token
"workspace/meetings/node_token.json", # google_meet node RPC secret
"weixin/accounts", # WeChat bot tokens + per-peer context tokens
".copilot_jwt.json", # exchanged Copilot API token
"runtime/photon-sidecar.json", # Photon sidecar auth token
"proxy", # iron-proxy CA key + proxy tokens
"chrome-debug", # /browser connect Chrome profile (cookies, logins)
"home", # subprocess HOME: gh, git, ssh, npm and skill-CLI credentials
+1
View File
@@ -83,6 +83,7 @@ _SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({
"auth.json", "auth.lock", "credentials", "config.yaml", ".anthropic_oauth.json",
"google_token.json", "google_oauth_pending.json", "google_oauth.json",
"webhook_subscriptions.json", "bws_cache.json", "bws_cache.enc.json",
"op_cache.json", "photon-sidecar.json",
".git-credentials", # git's credential-store cache (file_safety blocks it too)
})
+1 -1
View File
@@ -176,7 +176,7 @@ All env vars are documented in `plugin.yaml`. The most important:
- **Cron/standalone sends require a running gateway.** Processes outside
the gateway (cron subprocesses, `hermes send`) cannot spawn the sidecar;
they authenticate to the gateway's live sidecar via the runtime record at
`<hermes-home>/runtime/photon-sidecar.json` (written after the sidecar's
`<hermes-home>/photon-sidecar.json` (written after the sidecar's
`/healthz` readiness check, `0600`, removed on stop/failed start). Also
note that shared/free-tier Photon lines cannot INITIATE conversations
with numbers that never texted the line — that's Photon-side policy, not
+22 -8
View File
@@ -45,7 +45,7 @@ from gateway.platforms.event import MessageEvent, MessageType
from gateway.platforms.helpers import compile_mention_patterns, strip_markdown
from gateway.platforms.helpers import MessageDeduplicator, bounded_put, cancel_task
from gateway.platforms.send_targets import regex_stripped_parser
from utils import atomic_json_write
from utils import adopt_legacy_file, atomic_json_write
from .auth import load_project_credentials
# Sidecar dir resolution is lazy (never at import): it probes the filesystem and may
@@ -64,7 +64,9 @@ _MAX_MESSAGE_LENGTH = 8000 # iMessage caps practical size at ~16 KB; conservati
# token; persisted once /healthz passes, removed on every stop / failed-start path.
# --------------------------------------------------------------------------- Sidecar runtime record The
# gateway persists this record once the sidecar passes its /healthz readiness check, and removes it on every
# stop / failed-start path so a stale record never outlives a dead sidecar. See #69960.
# stop / failed-start path so a stale record never outlives a dead sidecar. See #69960. It sits at the top of
# the profile home, where a sandbox can hide it by path (a folder the agent writes, like runtime/, can be
# renamed to expose a hidden file below it).
_RUNTIME_RECORD_NAME = "photon-sidecar.json"
_DEDUP_MAX_SIZE = 4000 # the gRPC stream is at-least-once and a reconnect can replay
_DEDUP_WINDOW_SECONDS = 48 * 3600
@@ -103,28 +105,40 @@ async def _aiter_ndjson_lines(response: Any) -> AsyncIterator[str]:
def _runtime_record_path() -> Path:
from hermes_constants import get_hermes_home # honors profile overrides
return get_hermes_home() / "runtime" / _RUNTIME_RECORD_NAME
return get_hermes_home() / _RUNTIME_RECORD_NAME
def _legacy_runtime_record_path() -> Path:
"""Where earlier releases kept the record: ``<hermes-home>/runtime/``."""
path = _runtime_record_path()
return path.parent / "runtime" / path.name
def _live_runtime_record_path() -> Path:
"""The record path, after moving a record left at the legacy path (that path if the move fails)."""
return adopt_legacy_file(_runtime_record_path(), _legacy_runtime_record_path())
def _write_runtime_record(port: int, token: str, pid: int) -> None:
"""Atomically persist ``{port, token, pid}`` 0600 from creation (best-effort)."""
try:
atomic_json_write(_runtime_record_path(), {"port": port, "token": token, "pid": pid}, indent=None, mode=0o600)
atomic_json_write(_live_runtime_record_path(), {"port": port, "token": token, "pid": pid}, indent=None, mode=0o600)
except Exception as e:
logger.warning("[photon] failed to write sidecar runtime record: %s", e)
def _read_runtime_record() -> Optional[dict[str, Any]]:
try:
raw = json.loads(_runtime_record_path().read_text(encoding="utf-8-sig"))
raw = json.loads(_live_runtime_record_path().read_text(encoding="utf-8-sig"))
except (OSError, ValueError):
return None
return raw if isinstance(raw, dict) else None
def _delete_runtime_record() -> None:
with contextlib.suppress(OSError):
_runtime_record_path().unlink(missing_ok=True)
for path in (_runtime_record_path(), _legacy_runtime_record_path()):
with contextlib.suppress(OSError):
path.unlink(missing_ok=True)
def _sidecar_pid_alive(pid: Any) -> bool:
@@ -1626,7 +1640,7 @@ def _standalone_token_from_record(port: int) -> tuple[Optional[str], int, str]:
" — the gateway appears to be down.")
return None, port, (
"Photon standalone send requires a running sidecar. Start the Hermes gateway (which spawns "
f"the sidecar and records its address under <hermes-home>/runtime/{_RUNTIME_RECORD_NAME}), "
f"the sidecar and records its address in <hermes-home>/{_RUNTIME_RECORD_NAME}), "
"or set PHOTON_SIDECAR_TOKEN in this process's environment." + stale_hint)
+209
View File
@@ -0,0 +1,209 @@
"""Secret-source disk caches live directly in the profile home.
A sandbox that hides credential stores by path can hide a file safely only at the top of the
profile home: a writable folder above a hidden file can be renamed to expose it, and the
sandboxed backend has to write ``cache/``. Earlier releases kept these caches in ``cache/``; the
first read moves such a copy to the new location, and every path keeps a single live copy.
"""
from __future__ import annotations
import json
import os
import stat
import time
from pathlib import Path
import pytest
from agent.secret_sources import bitwarden as bw
from agent.secret_sources import onepassword as op
from agent.secret_sources._cache import CachedFetch, DiskCache
@pytest.fixture(autouse=True)
def _reset_caches():
bw._reset_cache_for_tests()
op._reset_cache_for_tests()
yield
bw._reset_cache_for_tests()
op._reset_cache_for_tests()
@pytest.fixture
def home(tmp_path):
home = tmp_path / "home"
home.mkdir()
return home
def _payload(key: str, secrets: dict) -> str:
return json.dumps({"key": key, "secrets": secrets, "fetched_at": time.time()})
def _previous(home: Path, name: str) -> Path:
path = home / "cache" / name
path.parent.mkdir(parents=True, exist_ok=True)
return path
def _refuse_rename(monkeypatch):
def refuse(*_args, **_kwargs):
raise PermissionError(13, "Access is denied")
monkeypatch.setattr(os, "rename", refuse)
def test_caches_live_at_the_top_of_the_profile_home(home):
assert bw._disk_cache_path(home) == home / "bws_cache.json"
assert bw._encrypted_disk_cache_path(home) == home / "bws_cache.enc.json"
assert op._STORE.disk.path(home) == home / "op_cache.json"
def test_a_write_lands_in_the_profile_home(home):
cache = DiskCache("probe.json", key_serializer=str)
cache.write("k", CachedFetch(secrets={"A": "b"}, fetched_at=time.time()), 60, home)
assert json.loads((home / "probe.json").read_text(encoding="utf-8"))["secrets"] == {"A": "b"}
assert not (home / "cache").exists()
def test_the_first_read_moves_a_cache_from_the_previous_location(home):
cache = DiskCache("probe.json", key_serializer=str)
previous = _previous(home, "probe.json")
previous.write_text(_payload("k", {"A": "b"}), encoding="utf-8")
entry = cache.read("k", 60, home)
assert entry is not None and entry.secrets == {"A": "b"}
assert (home / "probe.json").is_file()
assert not previous.exists()
@pytest.mark.platforms("posix") # POSIX permission bits
def test_the_move_keeps_the_file_mode(home):
cache = DiskCache("probe.json", key_serializer=str)
previous = _previous(home, "probe.json")
previous.write_text(_payload("k", {"A": "b"}), encoding="utf-8")
previous.chmod(0o600)
old_umask = os.umask(0o022) # a copy instead of a rename would come out 0o644
try:
assert cache.read("k", 60, home) is not None
finally:
os.umask(old_umask)
assert stat.S_IMODE((home / "probe.json").stat().st_mode) == 0o600
def test_a_current_cache_wins_and_the_previous_copy_is_removed(home):
cache = DiskCache("probe.json", key_serializer=str)
(home / "probe.json").write_text(_payload("k", {"A": "current"}), encoding="utf-8")
previous = _previous(home, "probe.json")
previous.write_text(_payload("k", {"A": "previous"}), encoding="utf-8")
entry = cache.read("k", 60, home)
assert entry is not None and entry.secrets == {"A": "current"}
assert not previous.exists()
def test_a_failed_move_keeps_working_from_the_previous_copy(home, monkeypatch):
cache = DiskCache("probe.json", key_serializer=str)
previous = _previous(home, "probe.json")
previous.write_text(_payload("k", {"A": "b"}), encoding="utf-8")
_refuse_rename(monkeypatch)
entry = cache.read("k", 60, home)
assert entry is not None and entry.secrets == {"A": "b"}
assert previous.is_file()
cache.write("k", CachedFetch(secrets={"A": "c"}, fetched_at=time.time()), 60, home)
copies = [p for p in (home / "probe.json", previous) if p.exists()]
assert len(copies) == 1, copies
assert json.loads(copies[0].read_text(encoding="utf-8"))["secrets"] == {"A": "c"}
def test_a_move_another_process_finished_reads_the_new_location(home, monkeypatch):
cache = DiskCache("probe.json", key_serializer=str)
previous = _previous(home, "probe.json")
previous.write_text(_payload("k", {"A": "b"}), encoding="utf-8")
real_rename = os.rename
def raced(src, dst):
real_rename(src, dst) # a concurrent reader moved it first
raise FileNotFoundError(2, "The system cannot find the file specified")
monkeypatch.setattr(os, "rename", raced)
entry = cache.read("k", 60, home)
assert entry is not None and entry.secrets == {"A": "b"}
def test_clear_removes_both_locations(home):
cache = DiskCache("probe.json", key_serializer=str)
(home / "probe.json").write_text(_payload("k", {"A": "b"}), encoding="utf-8")
previous = _previous(home, "probe.json")
previous.write_text(_payload("k", {"A": "b"}), encoding="utf-8")
cache.clear(home)
assert not (home / "probe.json").exists()
assert not previous.exists()
def test_onepassword_cache_moves_from_the_previous_location(home):
key = ("auth-fp", "account", str(home), "refs-fp")
previous = _previous(home, "op_cache.json")
previous.write_text(_payload(op._disk_key_str(key), {"OP_KEY": "v"}), encoding="utf-8")
entry = op._STORE.disk.read(key, 60, home)
assert entry is not None and entry.secrets == {"OP_KEY": "v"}
assert (home / "op_cache.json").is_file()
assert not previous.exists()
def _seed_encrypted(home: Path) -> tuple[tuple[str, str, str], Path]:
pytest.importorskip("cryptography")
cache_key = (bw._token_fingerprint("0.t"), "proj-1", "")
bw._write_encrypted_disk_cache(cache_key=cache_key, access_token="0.t",
entry=CachedFetch(secrets={"K": "v"}, fetched_at=time.time()),
home_path=home)
previous = _previous(home, "bws_cache.enc.json")
os.replace(home / "bws_cache.enc.json", previous)
return cache_key, previous
def test_bitwarden_encrypted_cache_moves_from_the_previous_location(home):
cache_key, previous = _seed_encrypted(home)
entry = bw._read_encrypted_disk_cache(cache_key=cache_key, access_token="0.t",
max_age_seconds=3600, home_path=home)
assert entry is not None and entry.secrets == {"K": "v"}
assert (home / "bws_cache.enc.json").is_file()
assert not previous.exists()
def test_bitwarden_encrypted_cache_failed_move_reads_the_previous_copy(home, monkeypatch):
cache_key, previous = _seed_encrypted(home)
_refuse_rename(monkeypatch)
entry = bw._read_encrypted_disk_cache(cache_key=cache_key, access_token="0.t",
max_age_seconds=3600, home_path=home)
assert entry is not None and entry.secrets == {"K": "v"}
assert previous.is_file()
assert not (home / "bws_cache.enc.json").exists()
def test_bitwarden_clear_caches_removes_previous_copies(home):
for name in ("bws_cache.json", "bws_cache.enc.json"):
_previous(home, name).write_text("{}", encoding="utf-8")
(home / name).write_text("{}", encoding="utf-8")
bw.clear_caches(home)
leftovers = [p for p in (*home.iterdir(), *(home / "cache").iterdir()) if p.is_file()]
assert leftovers == []
@@ -0,0 +1,105 @@
"""The Bitwarden and 1Password secret caches and the Photon sidecar record are covered by every guard
over credential stores (profile export, file tools, gateway media delivery, the dashboard Files API,
backups), at their current location and at the one earlier releases used.
The current paths come from the writers' own path functions, so moving a store without updating the
guards, or dropping it from one of them, fails here. The stores sit directly in the profile home: a
sandbox that hides files by path can hide them safely only there (a writable folder above a hidden file
can be renamed).
"""
from __future__ import annotations
from pathlib import Path
import pytest
# Where earlier releases kept the same stores; the first read moves them to the profile home.
PREVIOUS_LOCATIONS = (
"cache/bws_cache.json", "cache/bws_cache.enc.json", "cache/op_cache.json", "runtime/photon-sidecar.json",
)
@pytest.fixture
def home(tmp_path, monkeypatch):
home = tmp_path / "hermes"
home.mkdir()
monkeypatch.setenv("HERMES_HOME", str(home))
return home
def _current(home: Path) -> tuple[str, ...]:
from agent.secret_sources import bitwarden as bw
from agent.secret_sources import onepassword as op
from plugins.platforms.photon import adapter as photon
paths = (bw._disk_cache_path(home), bw._encrypted_disk_cache_path(home), op._STORE.disk.path(home),
photon._runtime_record_path())
return tuple(p.relative_to(home).as_posix() for p in paths)
def _stores(home: Path) -> tuple[str, ...]:
return (*_current(home), *PREVIOUS_LOCATIONS)
def _touch(base: Path, rel: str) -> Path:
path = base / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("{}", encoding="utf-8")
return path
def test_the_stores_sit_at_the_top_of_the_profile_home(home):
assert _current(home) == ("bws_cache.json", "bws_cache.enc.json", "op_cache.json", "photon-sidecar.json")
def test_profile_credential_paths_list_both_locations(home):
from hermes_cli.profiles import PROFILE_CREDENTIAL_PATHS, profile_path_is_private
for rel in _stores(home):
assert rel in PROFILE_CREDENTIAL_PATHS, rel
assert profile_path_is_private(tuple(rel.split("/"))), rel
def test_file_tools_refuse_to_read_or_write_them(home, monkeypatch):
import agent.file_safety as fs
root = home.parent / "root"
monkeypatch.setattr(fs, "_hermes_home_path", lambda: home)
monkeypatch.setattr(fs, "_hermes_root_path", lambda: root)
for base in (home, root):
for rel in _stores(home):
path = _touch(base, rel)
assert fs.get_read_block_error(str(path)), f"read allowed: {path}"
assert fs.is_write_denied(str(path)), f"write allowed: {path}"
def test_gateway_media_delivery_refuses_them(home, monkeypatch):
from gateway.platforms import base
monkeypatch.setattr(base, "_HERMES_HOME", home)
monkeypatch.setattr(base, "_HERMES_ROOT", home)
denied = base._media_delivery_denied_paths()
for rel in _stores(home):
path = _touch(home, rel)
assert path in denied, rel
assert base.validate_media_delivery_path(str(path)) is None, rel
def test_dashboard_files_api_hides_them(home):
from hermes_cli.web_routers.files import _is_sensitive_path
for rel in _stores(home):
assert _is_sensitive_path(home / rel), rel
def test_backups_leave_them_out(home):
from hermes_cli.backup import _should_exclude
for rel in _current(home):
assert _should_exclude(Path(rel)), rel
assert _should_exclude(Path("profiles", "work", rel)), rel
for rel in (r for r in PREVIOUS_LOCATIONS if r.startswith("cache/")): # cache/ is left out as a whole
assert _should_exclude(Path(rel)), rel
# Same names deeper in the tree are user data.
assert not _should_exclude(Path("skills", "demo", "op_cache.json"))
@@ -83,6 +83,10 @@ FLAT_INSTALL_RUNTIME_STATE = (
"google_token.json",
"google_oauth_pending.json",
"webhook_subscriptions.json",
"bws_cache.json",
"bws_cache.enc.json",
"op_cache.json",
"photon-sidecar.json",
"channel_directory.json",
"channel_aliases.json",
"feishu_comment_pairing.json",
@@ -750,6 +750,8 @@ def test_other_credential_store_basenames_blocked(forced_files_client):
"webhook_subscriptions.json",
"bws_cache.json",
"bws_cache.enc.json",
"op_cache.json",
"photon-sidecar.json",
):
p = root / name
p.write_text("SECRET=abc123")
@@ -3,7 +3,7 @@
The sidecar token is generated at spawn and used to exist only in the
gateway process memory + sidecar child env — so cron/`hermes send`
standalone sends structurally could not authenticate. The adapter now
persists ``<hermes-home>/runtime/photon-sidecar.json`` after the sidecar
persists ``<hermes-home>/photon-sidecar.json`` after the sidecar
passes its /healthz readiness check, deletes it on stop/failed-start, and
``_standalone_send`` falls back to it when PHOTON_SIDECAR_TOKEN is unset.
No Node, no ports, no network.
@@ -28,11 +28,19 @@ from plugins.platforms.photon.adapter import PhotonAdapter
@pytest.fixture()
def record_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
path = tmp_path / "runtime" / "photon-sidecar.json"
path = tmp_path / "photon-sidecar.json"
monkeypatch.setattr(photon_adapter, "_runtime_record_path", lambda: path)
return path
def _previous_record(record_path: Path, data: dict) -> Path:
"""A record where earlier releases kept it: ``<hermes-home>/runtime/``."""
previous = record_path.parent / "runtime" / record_path.name
previous.parent.mkdir(parents=True, exist_ok=True)
previous.write_text(json.dumps(data), encoding="utf-8")
return previous
def _make_adapter(monkeypatch: pytest.MonkeyPatch) -> PhotonAdapter:
monkeypatch.setenv("PHOTON_PROJECT_ID", "test-project-id")
monkeypatch.setenv("PHOTON_PROJECT_SECRET", "test-project-secret")
@@ -61,6 +69,48 @@ def test_write_read_delete_roundtrip(record_path: Path) -> None:
assert photon_adapter._read_runtime_record() is None
def test_record_lives_at_the_top_of_the_profile_home() -> None:
from hermes_constants import get_hermes_home
assert photon_adapter._runtime_record_path() == get_hermes_home() / "photon-sidecar.json"
def test_read_moves_a_record_from_the_previous_location(record_path: Path) -> None:
data = {"port": 8789, "token": "tok", "pid": 4242}
previous = _previous_record(record_path, data)
assert photon_adapter._read_runtime_record() == data
assert json.loads(record_path.read_text(encoding="utf-8")) == data
assert not previous.exists()
def test_a_failed_move_reads_the_previous_record(record_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
data = {"port": 8789, "token": "tok", "pid": 4242}
previous = _previous_record(record_path, data)
def refuse(*_args: Any, **_kwargs: Any) -> None:
raise PermissionError(13, "Access is denied")
monkeypatch.setattr(os, "rename", refuse)
assert photon_adapter._read_runtime_record() == data
assert previous.exists()
assert not record_path.exists()
def test_write_and_delete_leave_no_previous_record(record_path: Path) -> None:
previous = _previous_record(record_path, {"port": 1, "token": "old", "pid": 1})
photon_adapter._write_runtime_record(8789, "new", 4242)
assert not previous.exists()
assert json.loads(record_path.read_text(encoding="utf-8"))["token"] == "new"
_previous_record(record_path, {"port": 1, "token": "old", "pid": 1})
photon_adapter._delete_runtime_record()
assert not record_path.exists()
assert not previous.exists()
@pytest.mark.platforms("posix") # POSIX permission bits
def test_record_written_with_0600(record_path: Path) -> None:
photon_adapter._write_runtime_record(8789, "secret", 1)
+22
View File
@@ -341,6 +341,28 @@ def unlink_files_older_than(directory: str | Path, pattern: str, max_age_seconds
return removed
def adopt_legacy_file(path: str | Path, legacy: str | Path) -> Path:
"""Move the file at *legacy* (where an earlier release kept it) to *path*; return the path to use.
The move is a rename, so the file keeps its permissions. When *path* already exists the legacy
copy is a stale duplicate and is removed. When the move fails *legacy* is returned, so the caller
keeps working from the old copy and never holds two live copies. Never raises.
"""
path, legacy = Path(path), Path(legacy)
if not os.path.lexists(legacy):
return path
if os.path.lexists(path):
with suppress(OSError):
legacy.unlink()
return path
try:
os.rename(legacy, path)
except OSError:
if os.path.lexists(legacy): # still there; gone means another process moved it first
return legacy
return path
def rmtree_readonly(path: str | Path, *, ignore_errors: bool = False) -> None:
"""``shutil.rmtree`` that can also delete read-only trees.
+1 -1
View File
@@ -223,7 +223,7 @@ Common issues:
- **Cron and standalone sends need the gateway running.** Out-of-process
senders (cron jobs, `hermes send`, the dashboard) reuse the sidecar the
gateway spawned — they read its port/token from
`<hermes-home>/runtime/photon-sidecar.json`, written once the sidecar
`<hermes-home>/photon-sidecar.json`, written once the sidecar
passes its health check and removed when it stops. If a standalone send
reports the gateway appears to be down, start (or restart) the gateway
first.
+2 -2
View File
@@ -122,8 +122,8 @@ secrets:
| `project_id` | `""` | UUID of the project to sync from. |
| `server_url` | `""` | Bitwarden region or self-hosted endpoint. Empty = `bws` default (US Cloud, `https://vault.bitwarden.com`). Set to `https://vault.bitwarden.eu` for EU Cloud, or your own URL for self-hosted. Plumbed into the `bws` subprocess as `BWS_SERVER_URL`. |
| `cache_ttl_seconds` | `300` | How long an in-process or disk fetch result is reused. Set to `0` to disable fresh-cache reuse. |
| `encrypted_cache.enabled` | `false` | Store the last successful fetch in an AES-GCM encrypted cache at `~/.hermes/cache/bws_cache.enc.json`. |
| `encrypted_cache.max_stale_seconds` | `0` | When encrypted caching is enabled, allow that cache to be used only after network/timeout failures, up to this age. Authentication failures never use stale secrets. A successful encrypted write removes the legacy plaintext `cache/bws_cache.json`. |
| `encrypted_cache.enabled` | `false` | Store the last successful fetch in an AES-GCM encrypted cache at `~/.hermes/bws_cache.enc.json`. |
| `encrypted_cache.max_stale_seconds` | `0` | When encrypted caching is enabled, allow that cache to be used only after network/timeout failures, up to this age. Authentication failures never use stale secrets. A successful encrypted write removes the plaintext `bws_cache.json`. |
| `override_existing` | `true` | When true, Bitwarden values overwrite anything already in env (so rotation in the web app actually takes effect). Flip to `false` if you want `.env` / shell exports to win locally. |
| `auto_install` | `true` | Requests the PM-pinned `bws` package when no binary exists. PM's lazy-install policy also applies. |
@@ -146,7 +146,7 @@ Startup warnings now include a `→` remediation line telling you exactly which
## Caching
Successful, complete pulls are cached in-process and on disk under `<hermes_home>/cache/op_cache.json` (written atomically, mode `0600`), so back-to-back short-lived `hermes` invocations don't re-shell `op` for every reference. The cache:
Successful, complete pulls are cached in-process and on disk at `<hermes_home>/op_cache.json` (written atomically, mode `0600`), so back-to-back short-lived `hermes` invocations don't re-shell `op` for every reference. The cache:
- stores only resolved secret **values** — never the service-account token or any raw auth material (auth is fingerprinted into the cache key);
- is invalidated when the token, account, `OP_SESSION_*` variables, or the set of references change;
+1 -1
View File
@@ -377,7 +377,7 @@ These categories are always denied, even when `HERMES_WRITE_SAFE_ROOT` is unset:
| Category | Examples |
|----------|----------|
| OS credential stores | `~/.ssh/` (keys, `authorized_keys`), `~/.aws/`, `~/.kube/`, `/etc/sudoers`, `~/.netrc` |
| Hermes secret stores | `.env`, `.anthropic_oauth.json`, `auth/google_oauth.json`, Bitwarden cache (`cache/bws_cache.json`, `cache/bws_cache.enc.json`), `vault/`, `browser-profile/`, `mcp-tokens/`, `pairing/` under HERMES_HOME (active profile and global root). Control files (`auth.json`, `config.yaml`, `webhook_subscriptions.json`) are read-denied but stay writable. |
| Hermes secret stores | `.env`, `.anthropic_oauth.json`, `auth/google_oauth.json`, Bitwarden and 1Password caches (`bws_cache.json`, `bws_cache.enc.json`, `op_cache.json`), the Photon sidecar token (`photon-sidecar.json`), `vault/`, `browser-profile/`, `mcp-tokens/`, `pairing/` under HERMES_HOME (active profile and global root). Control files (`auth.json`, `config.yaml`, `webhook_subscriptions.json`) are read-denied but stay writable. |
| Windows NT/device-namespace paths | `\??\...`, `\\.\...`, `\\?\UNC\...`, `\\?\GLOBALROOT...` — rejected for both reads and writes on every platform. On Windows, merely *resolving* such a path (e.g. `\??\UNC\host\share`) triggers outbound SMB authentication and can leak the user's NTLM hash; the prefixes also bypass normal path normalization. Ordinary extended-length local paths (`\\?\C:\...`) and plain UNC shares (`\\server\share`) are unaffected. |
Project-local `.env`, `.env.local`, `.env.production` and `.envrc` files are **read-denied** anywhere on disk (the file tools refuse to read them) but remain writable: the agent can create or edit them for you, it just cannot read the values back.