From 9b6dcad91d7a6c34861eb1145b3ff0626d134a8d Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:26:43 -0700 Subject: [PATCH] fix(utils): writers that published through mkstemp on main keep NEW files at 0600 0dfb4234 made every mode-less atomic write follow the process umask for NEW targets, restoring what open("w")-based writers did. Ten of the folded sites were not open("w") writers: they created the file through mkstemp and never chmod'd, so on main a fresh file was 0600 regardless of umask (bot mailboxes, relay inbox, turn markers, sessions.json, cron jobs/output, banner snapshot, plugin toolset cache, presets, shell hooks, install id). CI caught the loosening in tests/tools/test_bot_live_owner_delivery.py (st_mode 0o077 bits set). Pass mode=0o600 explicitly at those ten sites; the umask default stays for the sites that were open("w") on main. Invariant test exercises two real writers. --- agent/shell_hooks.py | 2 +- cron/jobs.py | 4 ++-- gateway/session_persistence.py | 2 +- hermes_cli/banner.py | 2 +- hermes_cli/install_identity.py | 2 +- hermes_cli/local_runtime/presets.py | 2 +- hermes_cli/plugins.py | 2 +- tests/test_atomic_json_writers_unified.py | 22 ++++++++++++++++++++++ tools/bot_live_delivery.py | 2 +- tools/bot_relay.py | 2 +- tui_gateway/turn_marker.py | 2 +- 11 files changed, 33 insertions(+), 11 deletions(-) diff --git a/agent/shell_hooks.py b/agent/shell_hooks.py index 0b93ede6d5..96ab075102 100644 --- a/agent/shell_hooks.py +++ b/agent/shell_hooks.py @@ -468,7 +468,7 @@ def save_allowlist(data: Dict[str, Any]) -> None: """Atomic write; on OSError log and keep the in-process approval.""" p = allowlist_path() try: - atomic_json_write(p, data, sort_keys=True) + atomic_json_write(p, data, sort_keys=True, mode=0o600) except OSError as exc: logger.warning("Failed to persist shell hook allowlist to %s: %s. The approval is in-memory for this run, " "but the next startup will re-prompt (or skip registration on non-TTY runs without " diff --git a/cron/jobs.py b/cron/jobs.py index c41e65da44..8e3c2ac09e 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -1131,7 +1131,7 @@ def _write_marker(name: str, text: str, tmp_prefix: str) -> None: tick.""" try: ensure_dirs() - atomic_write_text(_current_cron_store().cron_dir / name, text, tmp_prefix=tmp_prefix) + atomic_write_text(_current_cron_store().cron_dir / name, text, tmp_prefix=tmp_prefix, mode=0o600) except Exception: pass @@ -3251,7 +3251,7 @@ def save_job_output(job_id: str, output: str): _ensure_cron_dir(job_output_dir) _secure_dir(job_output_dir) output_file = job_output_dir / f"{_hermes_now().strftime('%Y-%m-%d_%H-%M-%S')}.md" - atomic_write_text(output_file, output, tmp_prefix=".output_") + atomic_write_text(output_file, output, tmp_prefix=".output_", mode=0o600) _secure_file(output_file) # Bound per-job output growth so long-running deploys don't fill the disk (#52383). _prune_job_output(job_output_dir, _cron_output_keep()) diff --git a/gateway/session_persistence.py b/gateway/session_persistence.py index e881c735f1..c9df2f9091 100644 --- a/gateway/session_persistence.py +++ b/gateway/session_persistence.py @@ -473,7 +473,7 @@ class SessionPersistenceMixin: def _save_sessions_json(self, data: Dict[str, Any]) -> None: """Write the legacy sessions.json mirror of the routing index (atomic + fsync).""" - atomic_json_write(self.sessions_dir / "sessions.json", {"_README": _SESSIONS_JSON_README, **data}) + atomic_json_write(self.sessions_dir / "sessions.json", {"_README": _SESSIONS_JSON_README, **data}, mode=0o600) def _save_entries(self) -> None: """Snapshot latest state under ``_lock`` and persist after releasing it.""" diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 20ac5b2278..eb5be5addc 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -682,7 +682,7 @@ def save_banner_snapshot(tools: List[dict], enabled_toolsets: List[str], availab def _write(): from utils import atomic_json_write - atomic_json_write(_banner_snapshot_path(), payload, indent=None) + atomic_json_write(_banner_snapshot_path(), payload, indent=None, mode=0o600) _quiet(_write) diff --git a/hermes_cli/install_identity.py b/hermes_cli/install_identity.py index 21fc6fd4a3..2f25436359 100644 --- a/hermes_cli/install_identity.py +++ b/hermes_cli/install_identity.py @@ -76,7 +76,7 @@ def read_or_create_install_id(root: Path | None = None) -> Optional[str]: existing, mint = _read_existing(path) if not mint: return existing - atomic_write_text(path, uuid.uuid4().hex + "\n", tmp_prefix=".install_id-", fsync_dir=True) + atomic_write_text(path, uuid.uuid4().hex + "\n", tmp_prefix=".install_id-", fsync_dir=True, mode=0o600) committed = path.read_text(encoding="utf-8").strip().lower() return committed if _INSTALL_ID_RE.fullmatch(committed) else None except OSError: diff --git a/hermes_cli/local_runtime/presets.py b/hermes_cli/local_runtime/presets.py index 217957036a..fecd6ebb88 100644 --- a/hermes_cli/local_runtime/presets.py +++ b/hermes_cli/local_runtime/presets.py @@ -156,7 +156,7 @@ def generate_presets(models_dir: Path, budget: HardwareBudget, preset_path: Path sections.append(f"[{entry.model_id}]\n{body}\n") from utils import atomic_write_text - atomic_write_text(preset_path, "\n".join(sections), tmp_prefix=f".{preset_path.name}_") + atomic_write_text(preset_path, "\n".join(sections), tmp_prefix=f".{preset_path.name}_", mode=0o600) logger.info("wrote %d preset sections to %s", sum(e.keys is not None for e in entries), preset_path) return entries diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index facd240a58..000102babe 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -1627,7 +1627,7 @@ def _persist_plugin_toolset_keys() -> None: portable = sorted(get_plugin_manager().get_portable_mcp_servers()) except Exception: portable = [] - atomic_json_write(_plugin_toolset_keys_cache_path(), {"toolset_keys": keys, "portable_mcp": portable}, indent=None) + atomic_json_write(_plugin_toolset_keys_cache_path(), {"toolset_keys": keys, "portable_mcp": portable}, indent=None, mode=0o600) except Exception: logger.debug("plugin toolset key persist failed", exc_info=True) diff --git a/tests/test_atomic_json_writers_unified.py b/tests/test_atomic_json_writers_unified.py index 9f522125bb..10de99c7fe 100644 --- a/tests/test_atomic_json_writers_unified.py +++ b/tests/test_atomic_json_writers_unified.py @@ -110,3 +110,25 @@ def test_new_non_secret_file_follows_umask_while_secret_and_existing_modes_hold( assert stat.S_IMODE(existing.stat().st_mode) == 0o640 finally: os.umask(old_umask) + + +@pytest.mark.linux_only +def test_mkstemp_heritage_writers_keep_new_files_owner_only(tmp_path): + """Writers that published through mkstemp on main created NEW files at 0600 regardless of umask + (bot mailboxes, turn markers); folding them into utils must not loosen that to umask.""" + import os + import stat + + from tools.bot_relay import _atomic_write_json + from tui_gateway.turn_marker import _store + + old_umask = os.umask(0o022) + try: + relay_target = tmp_path / "relay" / "inbox.json" + _atomic_write_json(relay_target, {"k": 1}) + marker = tmp_path / "turn-marker.json" + _store(marker, {"sess": {"started_at": 1.0}}) + for path in (relay_target, marker): + assert stat.S_IMODE(path.stat().st_mode) == 0o600, path + finally: + os.umask(old_umask) diff --git a/tools/bot_live_delivery.py b/tools/bot_live_delivery.py index 2197bdbdb8..54d71c4d2b 100644 --- a/tools/bot_live_delivery.py +++ b/tools/bot_live_delivery.py @@ -97,7 +97,7 @@ def _read(path: Path) -> dict[str, Any] | None: def _write(path: Path, record: dict[str, Any]) -> None: - atomic_json_write(path, record, indent=None, sort_keys=True, fsync_dir=True) + atomic_json_write(path, record, indent=None, sort_keys=True, fsync_dir=True, mode=0o600) def deliver_to_live_owner( diff --git a/tools/bot_relay.py b/tools/bot_relay.py index 887daefac6..eb1fcc4a67 100644 --- a/tools/bot_relay.py +++ b/tools/bot_relay.py @@ -91,7 +91,7 @@ def _ensure_dirs(root: Path | str) -> Path: def _atomic_write_json(target: Path, payload: Any, *, sort_keys: bool = False) -> None: - atomic_json_write(target, payload, indent=None, sort_keys=sort_keys) + atomic_json_write(target, payload, indent=None, sort_keys=sort_keys, mode=0o600) def _bot_mode_cfg(key: str, *, loader: str) -> Any: diff --git a/tui_gateway/turn_marker.py b/tui_gateway/turn_marker.py index eefdc5de2d..81830e07ea 100644 --- a/tui_gateway/turn_marker.py +++ b/tui_gateway/turn_marker.py @@ -57,7 +57,7 @@ def _store(path: Path, entries: dict[str, dict]) -> None: if not entries: path.unlink(missing_ok=True) return - atomic_json_write(path, entries, indent=None) + atomic_json_write(path, entries, indent=None, mode=0o600) def _update(home: Path | str, session_key: str, mutate, what: str) -> None: