fix: block persistent self-restart jobs
This commit is contained in:
parent
30878411b8
commit
d2fa4590ef
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue