refactor(secret_scope): key the .env memo on utils.file_signature

The memo had its own (mtime_ns, size, inode, device) tuple and documented a
same-length/same-inode/pinned-mtime rewrite as an accepted gap. utils.file_signature is the
repo's change-detection key (config.load_env, skill_utils, prompt_builder already use it) and
includes ctime_ns, which user space cannot backdate — so the gap closes and one helper goes.
This commit is contained in:
kshitijk4poor
2026-09-17 20:32:32 +05:30
committed by kshitij
parent ee8999b6fb
commit 3711f36ee9

View File

@@ -20,6 +20,8 @@ from contextvars import ContextVar, Token
from pathlib import Path
from typing import Dict, Mapping, Optional, Tuple
from utils import file_signature
# Process-global (describes the deployment mode, not a per-task value): set once
# at gateway startup when gateway.multiplex_profiles is true.
@@ -225,23 +227,19 @@ def _parse_env_value(raw_value: str) -> str:
# turn, cron fire, MCP/browser adoption and housekeeping drain, and each call used to re-read and
# re-parse the whole file.
#
# FRESHNESS: every call still OPENS the file and keys on the ``fstat`` of that descriptor
# (mtime_ns, size, inode, device), re-checked after the read. The open keeps close-to-open
# FRESHNESS: every call still OPENS the file and keys on ``utils.file_signature`` of that
# descriptor's ``fstat`` (mtime_ns, size, inode, ctime_ns — ctime can't be backdated, so a pinned-
# timestamp rewrite is still seen), re-checked after the read. The open keeps close-to-open
# revalidation on NFS, a vanished/unreadable file fails the open and is never cached (a transient
# EACCES must not become "this profile has no secrets"), and the descriptor pins one inode so a
# symlink repointed mid-read can't file one file's contents under another's identity. Accepted gap: a
# rewrite keeping length, inode AND nanosecond mtime identical; ``invalidate_env_file_cache()`` is the
# knob, and ``hermes_cli.config.invalidate_env_cache()`` calls it for Hermes's own .env writers.
# symlink repointed mid-read can't file one file's contents under another's identity.
# ``invalidate_env_file_cache()`` is the explicit knob; ``hermes_cli.config.invalidate_env_cache()``
# calls it for Hermes's own .env writers.
_ENV_FILE_CACHE: "OrderedDict[str, Tuple[tuple, Dict[str, str]]]" = OrderedDict()
_ENV_FILE_CACHE_LOCK = threading.Lock()
_ENV_FILE_CACHE_MAX = 64 # one entry per profile home in practice
def _fd_fingerprint(fileno: int) -> tuple:
st = os.fstat(fileno)
return (st.st_mtime_ns, st.st_size, st.st_ino, st.st_dev)
def invalidate_env_file_cache(env_path: Optional[Path] = None) -> None:
"""Drop one path from the ``load_env_file()`` memo, or all of them."""
with _ENV_FILE_CACHE_LOCK:
@@ -293,7 +291,7 @@ def load_env_file(env_path: Path) -> Dict[str, str]:
key = str(env_path)
try:
with open(env_path, "rb") as handle:
fingerprint = _fd_fingerprint(handle.fileno())
fingerprint = file_signature(os.fstat(handle.fileno()))
with _ENV_FILE_CACHE_LOCK:
cached = _ENV_FILE_CACHE.get(key)
if cached is not None and cached[0] == fingerprint:
@@ -302,7 +300,7 @@ def load_env_file(env_path: Path) -> Dict[str, str]:
raw = handle.read()
# Same descriptor: a rewrite that landed between the fstat and the read is parsed but not
# stored under the pre-write fingerprint.
settled = _fd_fingerprint(handle.fileno()) == fingerprint
settled = file_signature(os.fstat(handle.fileno())) == fingerprint
except OSError:
# Gone or unreadable: drop any entry so a stale map cannot outlive the file.
invalidate_env_file_cache(env_path)