From b2cc4ceef0bf0ea4b8e61e5fa5c22f1912cde8e1 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:55:45 -0700 Subject: [PATCH] fix(cron): attribute cloud-path refusals to the cloud-synced script, not a lifecycle command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When check_gateway_lifecycle refuses a cron script that lives on a FileProvider path, the generic error implied the job contained a dangerous gateway lifecycle command. Surface the real reason instead — the script lives on a cloud-synced path whose evicted placeholder could hang the preflight scan — while staying fail-closed. Regression test asserts the cron-script scan path blocks without opening the file and that the message names the cloud-synced path rather than a lifecycle command. --- cron/lifecycle_guard.py | 23 +++++++++++ tests/hermes_cli/test_gateway_restart_loop.py | 39 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index b6bba13690..b3a6e648dc 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -746,6 +746,29 @@ def check_gateway_lifecycle( python_script = False if script: resolved_script = _resolve_script_path(script) + if resolved_script is not None: + try: + real_script = resolved_script.resolve(strict=False) + except (OSError, ValueError): + real_script = resolved_script + if _is_cloud_placeholder_path(resolved_script) or _is_cloud_placeholder_path( + real_script + ): + # Attribute the refusal correctly: the script is not known to + # contain a lifecycle command — it lives on a cloud-synced + # FileProvider path (iCloud Drive / ~/Library/CloudStorage) + # that the guard refuses to open because an evicted + # placeholder can hang preflight indefinitely (#88052). + # Fail closed with the real reason instead of implying a + # dangerous lifecycle command. + raise GatewayLifecycleBlocked( + "Blocked: the cron script lives on a cloud-synced path " + "(iCloud Drive / ~/Library/CloudStorage). Opening an " + "evicted FileProvider placeholder can hang the guard's " + "preflight scan indefinitely, so it is refused without " + "being read. Move the script to a local, non-cloud path " + "(e.g. ~/.hermes/scripts/) and recreate the job." + ) python_script = resolved_script is not None and resolved_script.suffix == ".py" script_text = _read_script_for_scanning(script) if script_text: diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 5043bea08e..acf6b99d8c 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -858,6 +858,45 @@ class TestLifecycleGuardModule: assert text is None assert unsafe is True + def test_cron_script_scan_blocks_cloud_script_without_opening( + self, tmp_path, monkeypatch + ): + """The cron-script scan path (_read_script_for_scanning via + check_gateway_lifecycle) must also refuse a cloud-resident script + without opening it, and the surfaced reason must attribute the + refusal to the cloud-synced path — not to a lifecycle command.""" + import cron.lifecycle_guard as lifecycle_guard + from cron.lifecycle_guard import ( + GatewayLifecycleBlocked, + check_gateway_lifecycle, + ) + + cloud_dir = ( + tmp_path + / "Library" + / "Mobile Documents" + / "com~apple~CloudDocs" + / "scripts" + ) + cloud_dir.mkdir(parents=True) + script = cloud_dir / "nightly.sh" + script.write_text("#!/bin/sh\necho safe\n", encoding="utf-8") + + real_open = lifecycle_guard.os.open + + def reject_cloud_open(path, flags, *args, **kwargs): + if str(path) == str(script): + pytest.fail("cron-script scan opened a cloud-resident script") + return real_open(path, flags, *args, **kwargs) + + monkeypatch.setattr(lifecycle_guard.os, "open", reject_cloud_open) + + with pytest.raises(GatewayLifecycleBlocked) as excinfo: + check_gateway_lifecycle("nightly job", str(script)) + message = str(excinfo.value) + assert "cloud-synced" in message + assert "lifecycle command" not in message + # -- Whole-class regression tests (tilllt's T1-T4 on PR #79454) -------- def test_tilde_nul_candidate_does_not_crash_terminal_walk(self):