diff --git a/hermes_cli/config.py b/hermes_cli/config.py index a981b1bbb..80dce6c04 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -197,14 +197,44 @@ def _ensure_default_soul_md(home: Path) -> None: def ensure_hermes_home(): - """Ensure ~/.hermes directory structure exists with secure permissions.""" + """Ensure ~/.hermes directory structure exists with secure permissions. + + In managed mode (NixOS), dirs are created by the activation script with + setgid + group-writable (2770). We skip mkdir and set umask(0o007) so + any files created (e.g. SOUL.md) are group-writable (0660). + """ home = get_hermes_home() - home.mkdir(parents=True, exist_ok=True) - _secure_dir(home) + if is_managed(): + old_umask = os.umask(0o007) + try: + _ensure_hermes_home_managed(home) + finally: + os.umask(old_umask) + else: + home.mkdir(parents=True, exist_ok=True) + _secure_dir(home) + for subdir in ("cron", "sessions", "logs", "memories"): + d = home / subdir + d.mkdir(parents=True, exist_ok=True) + _secure_dir(d) + _ensure_default_soul_md(home) + + +def _ensure_hermes_home_managed(home: Path): + """Managed-mode variant: verify dirs exist (activation creates them), seed SOUL.md.""" + if not home.is_dir(): + raise RuntimeError( + f"HERMES_HOME {home} does not exist. " + "Run 'sudo nixos-rebuild switch' first." + ) for subdir in ("cron", "sessions", "logs", "memories"): d = home / subdir - d.mkdir(parents=True, exist_ok=True) - _secure_dir(d) + if not d.is_dir(): + raise RuntimeError( + f"{d} does not exist. " + "Run 'sudo nixos-rebuild switch' first." + ) + # Inside umask(0o007) scope — SOUL.md will be created as 0660 _ensure_default_soul_md(home) diff --git a/hermes_logging.py b/hermes_logging.py index 6d8f4fa7b..5d71590c3 100644 --- a/hermes_logging.py +++ b/hermes_logging.py @@ -13,6 +13,7 @@ secrets are never written to disk. """ import logging +import os from logging.handlers import RotatingFileHandler from pathlib import Path from typing import Optional @@ -177,6 +178,38 @@ def setup_verbose_logging() -> None: # Internal helpers # --------------------------------------------------------------------------- +class _ManagedRotatingFileHandler(RotatingFileHandler): + """RotatingFileHandler that ensures group-writable perms in managed mode. + + In managed mode (NixOS), the stateDir uses setgid (2770) so new files + inherit the hermes group. However, both _open() (initial creation) and + doRollover() create files via open(), which uses the process umask — + typically 0022, producing 0644. This subclass applies chmod 0660 after + both operations so the gateway and interactive users can share log files. + """ + + def __init__(self, *args, **kwargs): + from hermes_cli.config import is_managed + self._managed = is_managed() + super().__init__(*args, **kwargs) + + def _chmod_if_managed(self): + if self._managed: + try: + os.chmod(self.baseFilename, 0o660) + except OSError: + pass + + def _open(self): + stream = super()._open() + self._chmod_if_managed() + return stream + + def doRollover(self): + super().doRollover() + self._chmod_if_managed() + + def _add_rotating_handler( logger: logging.Logger, path: Path, @@ -198,7 +231,7 @@ def _add_rotating_handler( return # already attached path.parent.mkdir(parents=True, exist_ok=True) - handler = RotatingFileHandler( + handler = _ManagedRotatingFileHandler( str(path), maxBytes=max_bytes, backupCount=backup_count, ) handler.setLevel(level) diff --git a/nix/nixosModules.nix b/nix/nixosModules.nix index 948f7df8c..b1be031df 100644 --- a/nix/nixosModules.nix +++ b/nix/nixosModules.nix @@ -560,10 +560,14 @@ # ── Directories ─────────────────────────────────────────────────── { systemd.tmpfiles.rules = [ - "d ${cfg.stateDir} 0750 ${cfg.user} ${cfg.group} - -" - "d ${cfg.stateDir}/.hermes 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir} 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/cron 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/sessions 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/logs 2770 ${cfg.user} ${cfg.group} - -" + "d ${cfg.stateDir}/.hermes/memories 2770 ${cfg.user} ${cfg.group} - -" "d ${cfg.stateDir}/home 0750 ${cfg.user} ${cfg.group} - -" - "d ${cfg.workingDirectory} 0750 ${cfg.user} ${cfg.group} - -" + "d ${cfg.workingDirectory} 2770 ${cfg.user} ${cfg.group} - -" ]; } @@ -575,7 +579,21 @@ mkdir -p ${cfg.stateDir}/home mkdir -p ${cfg.workingDirectory} chown ${cfg.user}:${cfg.group} ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory} - chmod 0750 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.stateDir}/home ${cfg.workingDirectory} + chmod 2770 ${cfg.stateDir} ${cfg.stateDir}/.hermes ${cfg.workingDirectory} + chmod 0750 ${cfg.stateDir}/home + + # Create subdirs, set setgid + group-writable, migrate existing files. + # Nix-managed files (config.yaml, .env, .managed) stay 0640/0644. + find ${cfg.stateDir}/.hermes -maxdepth 1 \ + \( -name "*.db" -o -name "*.db-wal" -o -name "*.db-shm" -o -name "SOUL.md" \) \ + -exec chmod g+rw {} + 2>/dev/null || true + for _subdir in cron sessions logs memories; do + mkdir -p "${cfg.stateDir}/.hermes/$_subdir" + chown ${cfg.user}:${cfg.group} "${cfg.stateDir}/.hermes/$_subdir" + chmod 2770 "${cfg.stateDir}/.hermes/$_subdir" + find "${cfg.stateDir}/.hermes/$_subdir" -type f \ + -exec chmod g+rw {} + 2>/dev/null || true + done # Merge Nix settings into existing config.yaml. # Preserves user-added keys (skills, streaming, etc.); Nix keys win. @@ -662,6 +680,10 @@ HERMES_NIX_ENV_EOF Restart = cfg.restart; RestartSec = cfg.restartSec; + # Shared-state: files created by the gateway should be group-writable + # so interactive users in the hermes group can read/write them. + UMask = "0007"; + # Hardening NoNewPrivileges = true; ProtectSystem = "strict"; diff --git a/tests/test_hermes_logging.py b/tests/test_hermes_logging.py index 5b40e6323..80a23dc68 100644 --- a/tests/test_hermes_logging.py +++ b/tests/test_hermes_logging.py @@ -2,6 +2,7 @@ import logging import os +import stat from logging.handlers import RotatingFileHandler from pathlib import Path from unittest.mock import patch @@ -300,6 +301,59 @@ class TestAddRotatingHandler: logger.removeHandler(h) h.close() + def test_managed_mode_initial_open_sets_group_writable(self, tmp_path): + log_path = tmp_path / "managed-open.log" + logger = logging.getLogger("_test_rotating_managed_open") + formatter = logging.Formatter("%(message)s") + + old_umask = os.umask(0o022) + try: + with patch("hermes_cli.config.is_managed", return_value=True): + hermes_logging._add_rotating_handler( + logger, log_path, + level=logging.INFO, max_bytes=1024, backup_count=1, + formatter=formatter, + ) + finally: + os.umask(old_umask) + + assert log_path.exists() + assert stat.S_IMODE(log_path.stat().st_mode) == 0o660 + + for h in list(logger.handlers): + if isinstance(h, RotatingFileHandler): + logger.removeHandler(h) + h.close() + + def test_managed_mode_rollover_sets_group_writable(self, tmp_path): + log_path = tmp_path / "managed-rollover.log" + logger = logging.getLogger("_test_rotating_managed_rollover") + formatter = logging.Formatter("%(message)s") + + old_umask = os.umask(0o022) + try: + with patch("hermes_cli.config.is_managed", return_value=True): + hermes_logging._add_rotating_handler( + logger, log_path, + level=logging.INFO, max_bytes=1, backup_count=1, + formatter=formatter, + ) + handler = next( + h for h in logger.handlers if isinstance(h, RotatingFileHandler) + ) + logger.info("a" * 256) + handler.flush() + finally: + os.umask(old_umask) + + assert log_path.exists() + assert stat.S_IMODE(log_path.stat().st_mode) == 0o660 + + for h in list(logger.handlers): + if isinstance(h, RotatingFileHandler): + logger.removeHandler(h) + h.close() + class TestReadLoggingConfig: """_read_logging_config() reads from config.yaml."""