fix: block persistent self-restart jobs

This commit is contained in:
John Lussier 2026-07-12 08:43:40 -07:00 committed by Teknium
parent 30878411b8
commit d2fa4590ef
3 changed files with 156 additions and 14 deletions

View File

@ -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.

View File

@ -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

View File

@ -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)