fix(config): stop forcing 0700 on HERMES_HOME inside containers

Every start (and every `ensure_hermes_home` from a sibling CLI invocation)
chmod'd the data directory to 0700, wiping group/other bits and the ACL
mask on a bind mount shared with other containers (hermes-webui, Nix
desktop + dashboard). _secure_file already skipped containers for this
reason; _secure_dir did not.

In a container the directory mode is now left to the operator unless
HERMES_HOME_MODE is set explicitly, which is still applied. cron/jobs.py
had its own 0700/0600 copies that bypassed the managed/container rules;
they now delegate to the shared helpers so cron/output stops re-locking
the mount as well.

Fixes #10757
This commit is contained in:
teknium1
2026-09-12 06:31:02 -07:00
committed by Teknium
parent cc6d86a83d
commit b78abb4710
4 changed files with 39 additions and 11 deletions

View File

@@ -524,16 +524,16 @@ def _is_recoverable_error_job(job: Dict[str, Any]) -> bool:
def _secure_dir(path: Path):
"""Set directory to owner-only access (0700). No-op where chmod is unsupported (Windows)."""
with contextlib.suppress(OSError, NotImplementedError):
os.chmod(path, 0o700)
"""Owner-only (0700) via the shared helper, so cron/ and cron/output honor the same managed/
container/HERMES_HOME_MODE rules as the rest of HERMES_HOME (#10757)."""
from hermes_cli.config import _secure_dir as _shared_secure_dir
_shared_secure_dir(path)
def _secure_file(path: Path):
"""Set file to owner-only read/write (0600). No-op where chmod is unsupported (Windows)."""
with contextlib.suppress(OSError, NotImplementedError):
if path.exists():
os.chmod(path, 0o600)
"""Owner-only (0600) via the shared helper (managed/container skip included)."""
from hermes_cli.config import _secure_file as _shared_secure_file
_shared_secure_file(path)
def _preserve_file_ownership(path: Path, before: Optional[os.stat_result]) -> None:

View File

@@ -549,9 +549,10 @@ def _chown_to_hermes_uid(path) -> None:
def _secure_dir(path):
"""chmod a directory owner-only (0700) and apply HERMES_UID/GID ownership. No-op when managed.
HERMES_HOME_MODE (e.g. 0701) overrides the mode so a web server can traverse HERMES_HOME to
a served subdirectory without directory listings.
"""chmod a directory owner-only (0700) and apply HERMES_UID/GID ownership. No-op when managed;
in a container only an explicit HERMES_HOME_MODE is applied. HERMES_HOME_MODE (e.g. 0701)
overrides the mode so a web server can traverse HERMES_HOME to a served subdirectory without
directory listings.
Also applies ``HERMES_UID``/``HERMES_GID``-based ownership when those env vars are set (#34107 — Docker
deployments need this so profile subdirs created at runtime by kanban workers don't land as root:root
@@ -559,8 +560,15 @@ def _secure_dir(path):
"""
if is_managed():
return
explicit_mode = os.environ.get("HERMES_HOME_MODE", "").strip()
# Same skip as _secure_file: a bind-mounted data dir is often shared with sibling containers
# running as other UIDs (web UI, permissions fixers); forcing 0700 on it locks them out on every
# start (#10757). An explicit HERMES_HOME_MODE is the operator's choice and is still applied.
if _is_container() and not explicit_mode:
_chown_to_hermes_uid(path)
return
try:
mode = int(os.environ.get("HERMES_HOME_MODE", "").strip() or "700", 8)
mode = int(explicit_mode or "700", 8)
except ValueError:
mode = 0o700
try:

View File

@@ -125,6 +125,22 @@ class TestSecureHelpers(unittest.TestCase):
from cron.jobs import _secure_file
_secure_file(Path("/nonexistent/path/file.json")) # Should not raise
def test_secure_dir_preserves_operator_mode_in_container(self):
"""A bind-mounted data dir shared with sibling containers must keep the operator's mode;
an explicit HERMES_HOME_MODE is still honored (#10757)."""
from cron.jobs import _secure_dir
with tempfile.TemporaryDirectory() as tmp:
d = Path(tmp) / "cron"
d.mkdir()
os.chmod(d, 0o755)
with patch.dict(os.environ, {"HERMES_CONTAINER": "1"}, clear=False):
os.environ.pop("HERMES_HOME_MODE", None)
_secure_dir(d)
self.assertEqual(stat.S_IMODE(os.stat(d).st_mode), 0o755)
os.environ["HERMES_HOME_MODE"] = "0701"
_secure_dir(d)
self.assertEqual(stat.S_IMODE(os.stat(d).st_mode), 0o701)
if __name__ == "__main__":
unittest.main()

View File

@@ -817,6 +817,10 @@ docker run -d \
`docker exec hermes <cmd>` automatically drops to UID 10000 too — see [`docker exec` automatically drops to the `hermes` user](#docker-exec-automatically-drops-to-the-hermes-user) for details and the per-invocation opt-out.
### Shared data directory keeps resetting to `0700`
Outside a container Hermes locks `HERMES_HOME` (and its `cron/`, `sessions/`, `logs/`, `memories/` subdirectories) to owner-only `0700` on every start. Inside a container it leaves directory modes alone, so a bind mount shared with a sibling container running as a different UID (a web UI, a permissions fixer) keeps whatever mode and ACLs you set on the host. To force a specific directory mode anyway, set `HERMES_HOME_MODE` (octal, e.g. `HERMES_HOME_MODE=0755`); it is applied in containers too.
### "Permission denied" on every `docker exec` (install dir locked to 0700)
Images built before late August 2026 had a bug where writing a credential file directly under `/opt/hermes` restricted that directory to `0700`, locking the `hermes` user (UID 10000) out of the install tree. Every new `docker exec` then fails with `Permission denied`.