Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions cron/lifecycle_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,17 @@ 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.
# 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)


Expand Down Expand Up @@ -398,10 +408,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 "
Expand Down
14 changes: 14 additions & 0 deletions tests/hermes_cli/test_gateway_restart_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
_contains_gateway_lifecycle_command,
cron_command,
)
from cron.lifecycle_guard import _iter_referenced_shell_scripts


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -628,6 +629,16 @@ 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"
"TMP = Path('/tmp') / 'workspace'\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
Expand Down Expand Up @@ -795,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"')) == []