diff --git a/cron/lifecycle_guard.py b/cron/lifecycle_guard.py index 6c70c1af8ae87..395b6e6fe43f2 100644 --- a/cron/lifecycle_guard.py +++ b/cron/lifecycle_guard.py @@ -23,19 +23,22 @@ autoscaling and restart behavior") would produce a high false-positive rate without preventing the actual foot-gun, which requires a real command shape. -This is a defence-in-depth layer. ``tools/terminal_tool.py`` already -blocks these commands at *execution* time when ``_HERMES_GATEWAY=1``, and -``hermes gateway stop|restart`` refuse to self-target from inside the -gateway. Blocking at *creation* time as well means the agent gets an -immediate, informative rejection instead of scheduling a job that will -only fail (silently) when it fires. +This is a defence-in-depth layer. ``tools/terminal_tool.py`` blocks direct +commands and shell scripts they reference when ``_HERMES_GATEWAY=1``. It also +rejects ``launchctl submit`` in gateway sessions because launchd treats that +primitive as a persistent KeepAlive job, not a one-shot task. ``hermes gateway +stop|restart`` separately refuse to self-target from inside the gateway. +Blocking cron specs at creation time as well means the agent gets an immediate, +informative rejection instead of scheduling a job that will only fail +(silently) when it fires. """ from __future__ import annotations import re +import shlex from pathlib import Path -from typing import Optional +from typing import Iterator, Optional class GatewayLifecycleBlocked(ValueError): @@ -73,6 +76,72 @@ def contains_gateway_lifecycle_command(text: str) -> bool: return bool(_GATEWAY_LIFECYCLE_PATTERN.search(text)) +_SHELL_EXECUTABLES = frozenset({"sh", "bash", "dash", "ksh", "zsh"}) +_LAUNCHCTL_SUBMIT_PATTERN = re.compile(r"(?i)\blaunchctl\s+submit\b") + + +def contains_launchctl_submit_command(command: str) -> bool: + """Return True for launchd's persistent ``launchctl submit`` primitive.""" + return bool(command and _LAUNCHCTL_SUBMIT_PATTERN.search(command)) + + +def _iter_referenced_shell_scripts( + command: str, + *, + cwd: Optional[str] = None, +) -> Iterator[Path]: + """Yield script files passed to shell executables in *command*. + + This covers direct execution (``bash script.sh``) and service-manager + wrappers such as ``launchctl submit ... -- /bin/bash script.sh``. Shell + ``-c`` payloads are already visible in the command text and are not paths. + """ + try: + tokens = shlex.split(command, posix=True) + except ValueError: + return + + for index, token in enumerate(tokens): + if Path(token).name not in _SHELL_EXECUTABLES: + continue + + candidate: Optional[str] = None + for argument in tokens[index + 1 :]: + if argument == "--": + continue + if argument in {"-c", "--command"}: + break + if argument.startswith("-"): + continue + candidate = argument + break + + if not candidate: + continue + path = Path(candidate).expanduser() + if not path.is_absolute(): + path = Path(cwd or Path.cwd()) / path + yield path + + +def contains_gateway_lifecycle_command_or_referenced_script( + command: str, + *, + cwd: Optional[str] = None, +) -> bool: + """Detect direct lifecycle commands and shell scripts containing one.""" + if contains_gateway_lifecycle_command(command): + return True + for script_path in _iter_referenced_shell_scripts(command, cwd=cwd): + try: + script_text = script_path.read_bytes().decode("utf-8", errors="replace") + except OSError: + continue + if contains_gateway_lifecycle_command(script_text): + return True + return False + + def _resolve_script_path(script_path: str) -> Path: """Resolve a cron ``script`` value the same way the scheduler does. diff --git a/tests/hermes_cli/test_gateway_restart_loop.py b/tests/hermes_cli/test_gateway_restart_loop.py index 59c61da114feb..bb0980a5bcdb3 100644 --- a/tests/hermes_cli/test_gateway_restart_loop.py +++ b/tests/hermes_cli/test_gateway_restart_loop.py @@ -256,6 +256,61 @@ class TestTerminalToolGatewayLifecycleGuard: assert result["exit_code"] == 1 assert "Blocked" in result["error"] + def test_blocks_lifecycle_command_hidden_in_referenced_script( + self, monkeypatch, tmp_path + ): + import tools.terminal_tool as tt + + script = tmp_path / "delayed-ops.sh" + script.write_text("#!/bin/bash\nsleep 45\nhermes gateway restart\n") + self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True) + + result = json.loads(tt.terminal_tool(command=f"/bin/bash {script}")) + + assert result["exit_code"] == 1 + assert "referenced script" in result["error"] + + def test_blocks_launchctl_submit_inside_gateway(self, monkeypatch, tmp_path): + import tools.terminal_tool as tt + + script = tmp_path / "health-check.sh" + script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n") + self._patch_env(monkeypatch, self._make_fake_env(), inside_gateway=True) + + result = json.loads(tt.terminal_tool( + command=( + "launchctl submit -l ai.hermes.delayed-ops -- " + f"/bin/bash {script}" + ) + )) + + assert result["exit_code"] == 1 + assert "KeepAlive" in result["error"] + + def test_safe_referenced_script_passes_through(self, monkeypatch, tmp_path): + import tools.terminal_tool as tt + + calls = [] + script = tmp_path / "health-check.sh" + script.write_text("#!/bin/bash\nprintf 'healthy\\n'\n") + + class _FakeEnv: + env = {} + def execute(self, command, **kwargs): + calls.append(command) + return {"output": "healthy", "returncode": 0} + + self._patch_env(monkeypatch, _FakeEnv(), inside_gateway=True) + monkeypatch.setattr( + tt, "_check_all_guards", lambda cmd, env, **kwargs: {"approved": True} + ) + command = f"/bin/bash {script}" + + result = json.loads(tt.terminal_tool(command=command)) + + assert result["exit_code"] == 0 + assert calls == [command] + def test_safe_systemctl_commands_pass_through(self, monkeypatch): """Non-hermes systemctl commands must not be blocked by this guard.""" import tools.terminal_tool as tt diff --git a/tools/terminal_tool.py b/tools/terminal_tool.py index 92ead50a21aa1..223490c65f263 100644 --- a/tools/terminal_tool.py +++ b/tools/terminal_tool.py @@ -2457,17 +2457,35 @@ def terminal_tool( # hermes_cli/gateway.py and the cron-path guard in hermes_cli/cron.py, # but applies unconditionally (force=True cannot help here). if os.environ.get("_HERMES_GATEWAY") == "1": - from hermes_cli.cron import _contains_gateway_lifecycle_command - if _contains_gateway_lifecycle_command(command): + from cron.lifecycle_guard import ( + contains_gateway_lifecycle_command_or_referenced_script, + contains_launchctl_submit_command, + ) + if contains_launchctl_submit_command(command): return json.dumps({ "output": "", "exit_code": 1, "error": ( - "Blocked: cannot restart or stop the gateway from inside the " - "gateway process. The gateway would kill this command before " - "it could complete (SIGTERM propagates to child processes). " - "Run `hermes gateway restart` from a separate shell outside " - "the running gateway." + "Blocked: launchctl submit creates a persistent KeepAlive job " + "and is unsafe from inside the gateway process. Use Hermes cron " + "for one-shot delayed work, or install an explicit LaunchAgent " + "from a separate shell." + ), + "status": "error", + }, ensure_ascii=False) + if contains_gateway_lifecycle_command_or_referenced_script( + command, + cwd=workdir or cwd, + ): + return json.dumps({ + "output": "", + "exit_code": 1, + "error": ( + "Blocked: command or referenced script cannot restart or stop " + "the gateway from inside the gateway process. The gateway would " + "kill this command before it could complete (SIGTERM propagates " + "to child processes). Run `hermes gateway restart` from a " + "separate shell outside the running gateway." ), "status": "error", }, ensure_ascii=False)