From 8666228a59a5512f4b058ad13398bb137d878cff Mon Sep 17 00:00:00 2001 From: Rashid Janahi Date: Mon, 3 Aug 2026 01:54:34 +0300 Subject: [PATCH 1/3] fix(cron): ignore pathlib division in lifecycle guard --- cron/lifecycle_guard.py | 8 +++++++- tests/hermes_cli/test_gateway_restart_loop.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index b09635c998bb9..be7c170992ed9 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -219,7 +219,13 @@ def _iter_referenced_shell_scripts( yield _resolve_terminal_script_path(arguments[arg_index], cwd) continue - if "/" in executable or executable.endswith((".sh", ".bash", ".zsh")): + # A standalone slash is a Python arithmetic operator in expressions + # such as ``Path.home() / ".hermes"``. It is not an executable path; + # treating it as one makes the scanner resolve ``/`` and fail closed + # on every ordinary pathlib-based Python cron script. + if (executable != "/" and "/" in executable) or executable.endswith( + (".sh", ".bash", ".zsh") + ): yield _resolve_terminal_script_path(executable, cwd) diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 840e26a0dfad5..f48be909547bf 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -628,6 +628,15 @@ def test_binary_script_does_not_silently_bypass(self, tmp_path): with pytest.raises(GatewayLifecycleBlocked): check_gateway_lifecycle("", str(script)) + def test_python_pathlib_division_does_not_look_like_script(self, tmp_path): + from cron.lifecycle_guard import check_gateway_lifecycle + script = tmp_path / "config.py" + script.write_text( + "from pathlib import Path\n" + "ENV = Path.home() / '.hermes' / '.env'\n" + ) + check_gateway_lifecycle("", str(script)) + def test_relative_script_resolved_under_scripts_dir(self, tmp_path, monkeypatch): """A bare/relative script name resolves under HERMES_HOME/scripts (the From 52987bb07d387ed3fb873aa501a6b6a3c511250f Mon Sep 17 00:00:00 2001 From: Rashid Janahi Date: Mon, 3 Aug 2026 02:13:13 +0300 Subject: [PATCH 2/3] fix(cron): avoid scanning Python as shell scripts --- cron/lifecycle_guard.py | 23 +++++++++++++++---- tests/hermes_cli/test_gateway_restart_loop.py | 1 + 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index be7c170992ed9..d1a8a9d959a0d 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -404,10 +404,25 @@ def check_gateway_lifecycle( combined = f"{combined}\n{script_text}" script_dir = _resolve_script_directory(script) if script else None - if contains_gateway_lifecycle_command_or_referenced_script( - combined, - cwd=script_dir, - ): + # Cron scripts are executed by the scheduler with Python unless they are + # shell scripts. The referenced-script walker is intentionally shell + # syntax-aware, so applying it to Python source misreads expressions such + # as `Path("/tmp") / "x"` as an executed absolute path and fails closed on + # the directory. Keep direct lifecycle-command matching for Python (and + # other non-shell) scripts, while retaining recursive shell scanning for + # shell scripts. + is_shell_script = bool( + script and Path(script).suffix.lower() in {".sh", ".bash", ".zsh"} + ) + blocked = ( + contains_gateway_lifecycle_command_or_referenced_script( + combined, + cwd=script_dir, + ) + if is_shell_script or not script + else contains_gateway_lifecycle_command(combined) + ) + if blocked: raise GatewayLifecycleBlocked( "Blocked: cron job contains a gateway lifecycle command or persistent " "launchctl submit operation. This is blocked to prevent agent-driven " diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index f48be909547bf..2468f72df53c6 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -634,6 +634,7 @@ def test_python_pathlib_division_does_not_look_like_script(self, tmp_path): script.write_text( "from pathlib import Path\n" "ENV = Path.home() / '.hermes' / '.env'\n" + "TMP = Path('/tmp') / 'workspace'\n" ) check_gateway_lifecycle("", str(script)) From 365b5c349f1429b92e2a1a52f366bb1c71b1b9ad Mon Sep 17 00:00:00 2001 From: Rashid Janahi Date: Mon, 3 Aug 2026 02:36:31 +0300 Subject: [PATCH 3/3] fix: ignore pathlib operands in shell script scan --- cron/lifecycle_guard.py | 10 +++++++--- tests/hermes_cli/test_gateway_restart_loop.py | 4 ++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index d1a8a9d959a0d..d732b615caded 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -223,9 +223,13 @@ def _iter_referenced_shell_scripts( # such as ``Path.home() / ".hermes"``. It is not an executable path; # treating it as one makes the scanner resolve ``/`` and fail closed # on every ordinary pathlib-based Python cron script. - if (executable != "/" and "/" in executable) or executable.endswith( - (".sh", ".bash", ".zsh") - ): + # Bare path-like tokens are not enough to identify shell scripts: + # Python expressions such as ``Path("/tmp") / "x"`` tokenize into + # ``/tmp`` and ``/`` and must not be treated as executable paths. + # Shell scripts without an explicit interpreter are still recognized + # by their conventional shell suffixes; explicit ``bash ./script`` + # invocations are handled above. + if executable.endswith((".sh", ".bash", ".zsh")): yield _resolve_terminal_script_path(executable, cwd) diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 2468f72df53c6..50336d53a3ff0 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -16,6 +16,7 @@ _contains_gateway_lifecycle_command, cron_command, ) +from cron.lifecycle_guard import _iter_referenced_shell_scripts # --------------------------------------------------------------------------- @@ -805,3 +806,6 @@ def test_cron_nested_wrapper_script_is_scanned(self, tmp_path, capsys, monkeypat assert rc == 1 out = capsys.readouterr().out assert "Blocked" in out + + def test_pathlib_absolute_operand_is_not_shell_script(self): + assert list(_iter_referenced_shell_scripts('Path("/tmp") / "x"')) == []