mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
088f657ecf
commit
2bed5b2258
22 files changed
+491
-51
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user