From 7140ff3c0edfa918cbfcd5130de357a8afb6b41c Mon Sep 17 00:00:00 2001 From: Meng Chee <99036697+Mengchee118@users.noreply.github.com> Date: Mon, 3 Aug 2026 11:40:08 -0700 Subject: [PATCH] fix(cron): scan dot-operator sourced scripts in lifecycle guard `_iter_referenced_shell_scripts` recognises the `source` builtin so a script pulled in with `source ./restart.sh` gets scanned for lifecycle commands. The POSIX dot operator is the same builtin, but it was not caught: if executable_name in {".", "source"}: `executable_name` is `Path(executable).name`, and `Path(".").name` is the **empty string** -- pathlib normalises "." to the current directory, whose name is "". So the set membership never matched for `.`, the sourced script was never added to the reference walk, and its contents were never scanned. Verified against current main: . /tmp/restart.sh -> not blocked (script never scanned) source /tmp/restart.sh -> blocked bash /tmp/restart.sh -> blocked where /tmp/restart.sh contains a `hermes gateway restart` line. Sourcing runs the script in the current shell, so the dot spelling is not merely equivalent to `source` -- it is the more common form in practice. Fix compares the raw token as well as the basename: if executable in {".", "source"} or executable_name == "source": Keeping the `executable_name == "source"` arm preserves the existing behaviour for a path-qualified spelling, while the raw-token test catches `.` without relying on pathlib normalisation. Tests (tests/hermes_cli/test_gateway_restart_loop.py): - test_dot_operator_sourced_script_is_scanned -- the regression; fails on main - test_source_builtin_sourced_script_is_scanned -- `source` stays blocked - test_dot_operator_clean_script_not_blocked -- widening the check must not false-block an innocent `. ./activate.sh` Found while auditing the guard after #76762. Scoped deliberately to this one defect; the NUL-padded-script bypass I found in the same audit is a separate PR. --- cron/lifecycle_guard.py | 6 ++- tests/hermes_cli/test_gateway_restart_loop.py | 43 +++++++++++++++++++ 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index 6c7a5eaad062..3ef4542acd49 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -190,7 +190,11 @@ def _iter_referenced_shell_scripts( executable = segment[index] executable_name = Path(executable).name - if executable_name in {".", "source"}: + # Compare the RAW token as well as the basename: `Path(".").name` is the + # empty string, so a basename-only test silently misses the dot operator + # while still catching `source`. `. ./restart.sh` is exactly equivalent + # to `source ./restart.sh`, so both must reach the referenced-script scan. + if executable in {".", "source"} or executable_name == "source": if len(segment) > index + 1: yield _resolve_terminal_script_path(segment[index + 1], cwd) continue diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index bd90e9913010..5177e7477d8e 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -568,6 +568,49 @@ def execute(self, command, **kwargs): class TestLifecycleGuardModule: """Direct tests for cron.lifecycle_guard.check_gateway_lifecycle.""" + def test_dot_operator_sourced_script_is_scanned(self, tmp_path): + """`. ./script.sh` must reach the referenced-script scan. + + The dot operator and `source` are the same POSIX builtin, but the + executable test compared only `Path(executable).name` — and + `Path(".").name` is the empty string, so `source` was caught while a + bare `.` slipped through and the sourced script was never scanned. + """ + from cron.lifecycle_guard import ( + contains_gateway_lifecycle_command_or_referenced_script, + ) + script = tmp_path / "restart.sh" + script.write_text("#!/bin/bash\nhermes gateway restart\n") + assert ( + contains_gateway_lifecycle_command_or_referenced_script(f". {script}") + is True + ) + + def test_source_builtin_sourced_script_is_scanned(self, tmp_path): + """The `source` spelling must stay blocked (it already was).""" + from cron.lifecycle_guard import ( + contains_gateway_lifecycle_command_or_referenced_script, + ) + script = tmp_path / "restart.sh" + script.write_text("#!/bin/bash\nhermes gateway restart\n") + assert ( + contains_gateway_lifecycle_command_or_referenced_script(f"source {script}") + is True + ) + + def test_dot_operator_clean_script_not_blocked(self, tmp_path): + """Widening the dot check must not false-block an innocent sourced + script — e.g. sourcing a venv activate or an env file.""" + from cron.lifecycle_guard import ( + contains_gateway_lifecycle_command_or_referenced_script, + ) + script = tmp_path / "activate.sh" + script.write_text("#!/bin/bash\nexport PATH=/usr/bin:$PATH\n") + assert ( + contains_gateway_lifecycle_command_or_referenced_script(f". {script}") + is False + ) + def test_prompt_with_command_raises(self): from cron.lifecycle_guard import GatewayLifecycleBlocked, check_gateway_lifecycle with pytest.raises(GatewayLifecycleBlocked) as exc: