From 9c7decdece093381ca10c9b947cc04cc31c0d186 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 14:50:38 +0000 Subject: [PATCH 1/2] fix(config): auto-reseal config.yaml sha256 on every authorised save Add get_config_seal_path(), seal_config(), and verify_config_integrity() helpers, and wire seal_config() into save_config() so the SHA256 seal file (~/.hermes/config.yaml.sha256) is updated on every authorised write. This prevents the Config Integrity Watchdog from raising false alarms for legitimate saves (e.g. free-model-scanner cron job, /model command). Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01MnmdLAbTdbTo66Uc2tK31x --- hermes_cli/config.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 19a0f4920318..786e16ab7c0e 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -13,6 +13,7 @@ """ import copy +import hashlib import logging import os import platform @@ -4582,6 +4583,49 @@ def save_config(config: Dict[str, Any]): ) _secure_file(config_path) _LAST_EXPANDED_CONFIG_BY_PATH[str(config_path)] = copy.deepcopy(current_normalized) + # Keep the integrity seal in sync so authorised saves (e.g. model + # scanner, /model command) don't trigger the Config Integrity Watchdog. + try: + seal_config() + except OSError: + pass + + +def get_config_seal_path() -> Path: + """Return the path to the config integrity seal file.""" + return get_config_path().parent / (get_config_path().name + ".sha256") + + +def seal_config() -> str: + """Compute SHA256 of the current config.yaml and write it to the seal file. + + Call this after any authorised out-of-band config mutation (e.g. a restore + script) so the Config Integrity Watchdog does not raise a false alarm. + Returns the hex digest that was written. + """ + config_path = get_config_path() + seal_path = get_config_seal_path() + digest = hashlib.sha256(config_path.read_bytes()).hexdigest() + seal_path.write_text(digest + "\n", encoding="utf-8") + _secure_file(seal_path) + return digest + + +def verify_config_integrity() -> tuple[bool, str, str]: + """Check whether config.yaml matches its integrity seal. + + Returns (ok, current_digest, sealed_digest). + ok is True when the file matches the seal (or no seal exists yet). + """ + config_path = get_config_path() + seal_path = get_config_seal_path() + if not config_path.exists(): + return True, "", "" + current = hashlib.sha256(config_path.read_bytes()).hexdigest() + if not seal_path.exists(): + return True, current, "" + sealed = seal_path.read_text(encoding="utf-8").strip() + return current == sealed, current, sealed def load_env() -> Dict[str, str]: From cbaf19d079d73d12cf0afa5226637cb93facc5ba Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 15:06:43 +0000 Subject: [PATCH 2/2] fix(backup): include config seal in snapshots and reseal on restore Add config.yaml.sha256 to _QUICK_STATE_FILES so the Config Integrity Watchdog seal file is captured alongside config.yaml in quick snapshots. After restore_quick_snapshot() restores files, call seal_config() when config.yaml was among the restored files so the watchdog sees an authorized restore rather than tampering. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01MnmdLAbTdbTo66Uc2tK31x --- hermes_cli/backup.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/hermes_cli/backup.py b/hermes_cli/backup.py index a137509d7b12..f71542fa68a7 100644 --- a/hermes_cli/backup.py +++ b/hermes_cli/backup.py @@ -479,6 +479,7 @@ def run_import(args) -> None: _QUICK_STATE_FILES = ( "state.db", "config.yaml", + "config.yaml.sha256", # seal file for Config Integrity Watchdog ".env", "auth.json", "cron/jobs.json", @@ -655,6 +656,16 @@ def restore_quick_snapshot( logger.error("Failed to restore %s: %s", rel, exc) logger.info("Restored %d files from snapshot %s", restored, snapshot_id) + + # Reseal if config.yaml was among the restored files so the Config + # Integrity Watchdog sees an authorized restore, not tampering. + if restored > 0 and "config.yaml" in meta.get("files", {}): + try: + from hermes_cli.config import seal_config + seal_config() + except (OSError, ImportError): + pass + return restored > 0