diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index 6c70c1af8ae8..65306a02fa1d 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -100,12 +100,20 @@ def _read_script_for_scanning(script_path: str) -> str: ``UnicodeDecodeError`` on such files, and swallowing that error would let an attacker hide the command in binary noise. Returns an empty string only when the file cannot be read at all. + + ``_resolve_script_path``'s ``Path.expanduser()`` raises ``RuntimeError`` + (not ``OSError``) for a ``~user``-shaped path with no matching system + user — e.g. an LLM-authored ``script`` value that happens to start with + ``~someuser/...``. Catch it alongside ``OSError`` so a malformed script + path degrades to "can't scan it" instead of crashing job creation with an + unhandled exception (same bug class as agent/subdirectory_hints.py's + tilde-expansion crash). """ try: return _resolve_script_path(script_path).read_bytes().decode( "utf-8", errors="replace" ) - except OSError: + except (OSError, RuntimeError): return "" diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 03ef426ec896..dc0dc72285e8 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -428,6 +428,23 @@ def test_missing_script_does_not_raise(self, tmp_path): from cron.lifecycle_guard import check_gateway_lifecycle check_gateway_lifecycle("clean prompt", str(tmp_path / "nonexistent.sh")) + def test_tilde_unknown_user_script_does_not_crash(self): + """A ``~user``-shaped script path with no matching system user must + degrade to "can't scan it" instead of raising RuntimeError. + + ``Path.expanduser()`` raises ``RuntimeError`` (not ``OSError``) for + an unresolvable ``~user`` prefix — same bug class as + agent/subdirectory_hints.py's tilde-expansion crash (an LLM-authored + script value like ``~nonexistent_user_xyzzy_12345/restart.sh`` must + not crash cron job creation). + """ + from cron.lifecycle_guard import check_gateway_lifecycle + # Must not raise RuntimeError — the guard treats an unscannable + # script as absent and only judges the (clean) prompt. + check_gateway_lifecycle( + "clean prompt", "~nonexistent_user_xyzzy_12345/restart.sh", + ) + def test_relative_script_resolved_under_scripts_dir(self, tmp_path, monkeypatch): """A bare/relative script name resolves under HERMES_HOME/scripts (the same place the scheduler runs it from) — otherwise the guard would read