hermes-agent/cron/lifecycle_guard.py

715 lines
30 KiB
Python

"""Gateway lifecycle guard for cron job creation (#30719).
An agent running inside a gateway can schedule a cron job that calls
``hermes gateway restart`` (or ``launchctl kickstart ai.hermes.gateway``
or ``systemctl restart hermes-gateway``). When the cron fires, the
gateway dies, the supervisor (launchd KeepAlive / systemd Restart=)
revives it, auto-resume picks up the offending session, and the resumed
turn re-runs the same logic — a SIGTERM-respawn loop every ~10 seconds
until manually broken.
This module rejects cron job specs whose prompt or script contains a
direct shell-level gateway-lifecycle command. It is enforced at
``cron.jobs.create_job`` so it fires on every job-creation path: the
``hermes cron create`` CLI subcommand AND the agent's ``cronjob`` model
tool (which calls ``create_job`` directly, bypassing the CLI layer).
The pattern is intentionally command-shaped: it anchors on a concrete
command identifier (``hermes gateway``, ``launchctl ... hermes-gateway``,
``systemctl ... hermes-gateway``, ``pkill`` against the gateway) so it
cannot fire on prose. A cron ``prompt`` is fed to a future LLM, not a
shell, so an over-broad substring match on English ("Kong API gateway
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`` 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 logging
import os
import re
import shlex
import stat
from pathlib import Path
from typing import Callable, Iterator, Optional
logger = logging.getLogger(__name__)
class GatewayLifecycleBlocked(ValueError):
"""Raised when a cron job spec contains a gateway-lifecycle command."""
# Shell-level command shapes that target the gateway lifecycle. Each branch
# is anchored on a concrete command identifier so a match can only fire on
# actual shell-command-shaped strings, not on prose.
_GATEWAY_LIFECYCLE_PATTERN = re.compile(
r"(?i)"
# Branch A: `hermes gateway restart|stop` — the canonical foot-gun.
# `start` is intentionally excluded: starting a gateway from inside a
# gateway is benign (a no-op or "already running" error), and a
# legitimate cron job might start a sibling profile's gateway.
r"(?:hermes\s+gateway\s+(?:restart|stop))"
# Branch B: launchctl ops on a hermes-gateway label. macOS launchd
# labels look like `ai.hermes.gateway` / `hermes-gateway`. Requiring the
# gateway identifier prevents blocking unrelated hermes services (e.g.
# `launchctl unload ai.hermes.update-checker.plist`).
# `submit` and `bootstrap` are included alongside the direct verbs
# (kickstart/etc.): `launchctl submit -l ai.hermes.gateway-<suffix> --
# <helper-script>` (or `launchctl bootstrap gui/<uid> <plist>`) creates
# a NEW keepalive job wrapping an arbitrary helper, which is how a
# blocked direct restart/kill gets laundered into a persistent restart
# loop instead (#62891) — same foot-gun, indirect shape. Neutral-label
# submissions that dodge this text anchor are caught separately by
# `contains_launchctl_submit_command` (execution-aware, label-independent).
r"|(?:launchctl\s+(?:kickstart|unload|load|stop|restart|submit|bootstrap)\b[^\n]*\bhermes[.\-]?gateway)"
# Branch C: systemctl ops on a hermes-gateway unit.
r"|(?:systemctl\s+(?:-\S+\s+)*(?:restart|stop|start)\b[^\n]*\bhermes[.\-]?gateway)"
# Branch D: pkill / kill targeting the hermes gateway process. Both
# token orders because real reproductions show both.
r"|(?:p?kill\b[^\n]*\bhermes\b[^\n]*\bgateway)"
r"|(?:p?kill\b[^\n]*\bgateway\b[^\n]*\bhermes)"
)
# A backslash immediately followed by a newline is a POSIX shell line
# continuation — the shell joins the two lines before parsing. Every branch
# above uses `[^\n]*` between its verb and the gateway identifier so the
# match can't span unrelated lines of a longer cron prompt/script, but that
# also means a real multi-line shell invocation split across continuation
# lines (e.g. `launchctl submit \` / ` -l ai.hermes.gateway-... \` / ` -- ...`,
# the exact reported shape in #62891) would otherwise slip past. Collapse
# continuations to a single space before matching, mirroring what the shell
# itself does, rather than loosening `[^\n]*` and risking false positives
# across genuinely separate lines.
_SHELL_LINE_CONTINUATION = re.compile(r"\\\r?\n[ \t]*")
def contains_gateway_lifecycle_command(text: str) -> bool:
"""Return True if *text* contains a gateway lifecycle command pattern."""
if not text:
return False
normalized = _SHELL_LINE_CONTINUATION.sub(" ", text)
return bool(_GATEWAY_LIFECYCLE_PATTERN.search(normalized))
_SHELL_EXECUTABLES = frozenset({"sh", "bash", "dash", "ksh", "zsh"})
_SHELL_OPTIONS_WITH_VALUES = frozenset({"-O", "+O", "-o", "+o"})
_MAX_REFERENCED_SCRIPT_BYTES = 1024 * 1024
_MAX_REFERENCED_SCRIPT_DEPTH = 8
_CONTROL_CHARS = frozenset(";&|()")
# Executables whose arguments are DATA, not commands: search patterns, SQL
# statements, log filters. None of these can execute their argument text, so
# a lifecycle-shaped string inside their arguments (a grep pattern hunting
# for `systemctl restart hermes-gateway` in syslog, a SQL LIKE literal over a
# restart-events table) is diagnostics, not a lifecycle command. Deliberately
# conservative: no `awk` (system()), no `sed` (`s///e`), no `echo`/`printf`
# (routinely piped into a shell), no `mysql` (`\\!` and `system` escapes).
_DATA_SINK_EXECUTABLES = frozenset(
{"grep", "egrep", "fgrep", "rg", "ag", "ack", "journalctl", "sqlite3", "psql"}
)
# Argument shapes that can smuggle execution back INTO a data sink: command
# and process substitution anywhere, sqlite3 dot-commands (`.shell ...`),
# psql backslash escapes (`\! ...`). Any hit disables masking for the whole
# segment — fail closed to the plain regex verdict.
_UNSAFE_DATA_ARG_MARKERS = ("`", "$(", "<(", ">(", "\\!")
# A data sink piped into a shell/interpreter can feed matched lines straight
# to execution (`grep 'systemctl restart hermes-gateway' f | sh`); never mask
# such a line.
_PIPE_TO_INTERPRETER = re.compile(
r"\|\s*&?\s*(?:sudo\s+)?(?:sh|bash|dash|ksh|zsh|xargs|eval|source)\b"
)
# Executable-image magic numbers: ELF, PE/COFF, Mach-O (universal + thin,
# both endiannesses). A referenced file starting with one of these is a
# compiled binary, never a shell script — don't read or scan it at all.
_BINARY_MAGIC_PREFIXES = (
b"\x7fELF",
b"MZ",
b"\xca\xfe\xba\xbe",
b"\xcf\xfa\xed\xfe",
b"\xce\xfa\xed\xfe",
b"\xfe\xed\xfa\xce",
b"\xfe\xed\xfa\xcf",
)
_BINARY_SNIFF_BYTES = 4096
_ReadRemoteScriptFn = Callable[[str], Optional[str]]
def _iter_command_segments(command: str) -> Iterator[list[str]]:
"""Yield shell-tokenized command segments, honoring quotes and comments."""
normalized = command.replace("\\\n", "")
for line in normalized.splitlines() or [normalized]:
try:
lexer = shlex.shlex(
line,
posix=True,
punctuation_chars=";&|()",
)
lexer.whitespace_split = True
lexer.commenters = "#"
tokens = list(lexer)
except ValueError:
continue
segment: list[str] = []
for token in tokens:
if token and set(token) <= _CONTROL_CHARS:
if segment:
yield segment
segment = []
continue
segment.append(token)
if segment:
yield segment
def _command_token_index(segment: list[str]) -> Optional[int]:
"""Return the executable token index after simple env assignments."""
for index, token in enumerate(segment):
if re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", token):
continue
return index
return None
def contains_launchctl_submit_command(command: str) -> bool:
"""Detect an executed ``launchctl submit``/``bootstrap``, not quoted text.
Label-independent by design: the label of a submitted/bootstrapped job is
chosen by whoever writes it, so a neutral name (``ai.hermes.svc-reload-tmp``)
defeats any label-anchored regex (#62891, second reproduction). Both verbs
register a NEW persistent launchd job (``submit`` jobs get KeepAlive
semantics; ``bootstrap`` loads an arbitrary plist), which is never safe to
do from inside the gateway process.
"""
for segment in _iter_command_segments(command):
index = _command_token_index(segment)
if index is None:
continue
if Path(segment[index]).name == "launchctl":
arguments = segment[index + 1 :]
if arguments and arguments[0].lower() in {"submit", "bootstrap"}:
return True
return False
def _mask_data_sink_arguments(text: str) -> str:
"""Replace data-sink executables' arguments with a neutral placeholder.
The lifecycle regex is command-shaped, but it cannot tell an EXECUTED
``systemctl restart hermes-gateway`` from the same characters appearing
as *data* — a grep/rg pattern, a journalctl filter, a SQL string literal
passed to sqlite3/psql. Those diagnostics commands were being rejected
(false positives blocking legitimate cron prompts), e.g.::
grep -c 'systemctl restart hermes-gateway' /var/log/syslog
sqlite3 db "SELECT msg FROM log WHERE msg LIKE '%systemctl restart hermes-gateway%'"
This masker shell-tokenizes each line and, for command segments whose
executable is a known data sink (``_DATA_SINK_EXECUTABLES``), replaces
every argument with ``arg``. The caller then re-runs the lifecycle regex
on the masked text: a match that survives masking sits OUTSIDE any data
argument and is a real command.
Strictly fail-closed: masking is skipped (leaving the original,
regex-matching text in place) whenever the line pipes into a shell or
interpreter, any argument carries an execution-capable marker
(substitution, sqlite3 ``.``-commands, psql ``\\!``), or the line cannot
be tokenized at all. Masking can therefore only ever ALLOW a command the
plain regex would have blocked — never block one it would have allowed —
so it runs solely as a second-pass exemption check.
"""
lines_out: list[str] = []
changed = False
for line in text.splitlines() or [text]:
if _PIPE_TO_INTERPRETER.search(line):
lines_out.append(line)
continue
try:
lexer = shlex.shlex(line, posix=True, punctuation_chars=";&|()")
lexer.whitespace_split = True
lexer.commenters = "#"
tokens = list(lexer)
except ValueError:
lines_out.append(line)
continue
segments: list[list[str]] = []
current: list[str] = []
for token in tokens:
if token and set(token) <= _CONTROL_CHARS:
segments.append(current)
segments.append([token])
current = []
continue
current.append(token)
segments.append(current)
rebuilt: list[str] = []
for segment in segments:
if not segment:
continue
index = _command_token_index(segment)
if index is not None and Path(segment[index]).name in _DATA_SINK_EXECUTABLES:
arguments = segment[index + 1 :]
if not any(
argument.startswith(".")
or any(marker in argument for marker in _UNSAFE_DATA_ARG_MARKERS)
for argument in arguments
):
changed = True
rebuilt.extend(segment[: index + 1])
rebuilt.extend("arg" for _ in arguments)
continue
rebuilt.extend(segment)
lines_out.append(" ".join(rebuilt))
if not changed:
return text
return "\n".join(lines_out)
def _lifecycle_command_scan_with_data_exemption(text: str) -> bool:
"""Lifecycle-regex scan that exempts matches living inside data arguments.
Two-pass: the cheap regex first (the overwhelmingly common no-match case
pays nothing extra); on a raw match, re-scan with data-sink arguments
masked out. Only a match that survives masking — i.e. one in actual
command position — blocks.
"""
if not contains_gateway_lifecycle_command(text):
return False
normalized = _SHELL_LINE_CONTINUATION.sub(" ", text)
return contains_gateway_lifecycle_command(_mask_data_sink_arguments(normalized))
def _direct_lifecycle_scan(command: str) -> bool:
"""Pure-string direct scans: lifecycle regex (data-exempted) + submit."""
return _lifecycle_command_scan_with_data_exemption(
command
) or contains_launchctl_submit_command(command)
def _expand_candidate_path(candidate: str) -> Optional[Path]:
"""Sanitize a tokenized path candidate at the ingestion boundary.
Candidate tokens come from shlex-splitting arbitrary command text —
including text recursively decoded from binaries or remote reads — so
they can carry NUL bytes or other junk no real filesystem path can
contain. Every OS-facing ``Path`` operation downstream (``expanduser``,
``os.open``, ``resolve``) raises a *different* exception for the same
junk (``ValueError: embedded null byte``, ``RuntimeError: Could not
determine home directory`` when HOME is unset under launchd, OSError
for over-long paths). Rejecting here — once, before any OS call — is
the whole-class fix; catching per-syscall was the whack-a-mole that
produced #76762, #77703, #77780, and #78256.
Returns ``None`` for candidates that cannot be a real path (nothing to
scan), otherwise the ``expanduser()``-expanded ``Path``.
"""
if not candidate or "\x00" in candidate:
return None
try:
return Path(candidate).expanduser()
except (ValueError, RuntimeError, OSError):
return None
def _resolve_terminal_script_path(candidate: str, cwd: Optional[str]) -> Optional[Path]:
path = _expand_candidate_path(candidate)
if path is None:
return None
if not path.is_absolute():
try:
path = Path(cwd or Path.cwd()) / path
except OSError:
# Path.cwd() can raise when the process cwd was deleted.
return None
return path
def _iter_referenced_shell_scripts(
command: str,
*,
cwd: Optional[str] = None,
) -> Iterator[Path]:
"""Yield scripts executed directly or through a POSIX shell."""
for segment in _iter_command_segments(command):
index = _command_token_index(segment)
if index is None:
continue
executable = segment[index]
executable_name = Path(executable).name
if executable_name in {".", "source"}:
if len(segment) > index + 1:
resolved = _resolve_terminal_script_path(segment[index + 1], cwd)
if resolved is not None:
yield resolved
continue
if executable_name in _SHELL_EXECUTABLES:
arguments = segment[index + 1 :]
arg_index = 0
while arg_index < len(arguments):
argument = arguments[arg_index]
if argument == "--":
arg_index += 1
break
if argument in {"-c", "--command"}:
break
if argument in _SHELL_OPTIONS_WITH_VALUES:
arg_index += 2
continue
if argument.startswith("-"):
arg_index += 1
continue
break
if arg_index < len(arguments) and arguments[arg_index] not in {
"-c",
"--command",
}:
resolved = _resolve_terminal_script_path(arguments[arg_index], cwd)
if resolved is not None:
yield resolved
continue
# A bare "/" token is pathlib's division operator in Python sources
# (e.g. `Path.home() / ".hermes"`), not an executable reference.
# Resolving it walks to the filesystem root and fails the
# regular-file check below, hard-blocking innocent .py scripts
# (#77131). Skip pure-separator tokens.
if executable.strip("/"):
if "/" in executable or executable.endswith((".sh", ".bash", ".zsh")):
resolved = _resolve_terminal_script_path(executable, cwd)
if resolved is not None:
yield resolved
def _iter_shell_command_payloads(command: str) -> Iterator[str]:
"""Yield code passed through ``sh|bash|... -c`` for recursive scanning."""
for segment in _iter_command_segments(command):
index = _command_token_index(segment)
if index is None or Path(segment[index]).name not in _SHELL_EXECUTABLES:
continue
arguments = segment[index + 1 :]
for arg_index, argument in enumerate(arguments[:-1]):
if argument in {"-c", "--command"}:
yield arguments[arg_index + 1]
break
def _resolve_script_directory(script_path: str) -> Optional[str]:
"""Return the directory *script_path* resolves to, handling relative names."""
try:
path = _resolve_script_path(script_path)
if path is not None and path.is_absolute():
return str(path.parent)
except Exception:
pass
return None
def _read_referenced_script(path: Path) -> tuple[Optional[str], bool]:
"""Return ``(text, unsafe)`` using bounded, regular-file-only reads."""
flags = os.O_RDONLY | getattr(os, "O_NONBLOCK", 0)
try:
descriptor = os.open(path, flags)
except (OSError, ValueError):
# OSError: unreadable / missing / over-long paths. ValueError: an
# embedded NUL byte in *path* itself — a binary's decoded bytes
# tokenized into a bogus script path by the recursion (#77703). A
# guarded read must never crash the guard, so treat either as
# "nothing to scan" (mirrors the resolve() ValueError guard below).
return None, False
try:
metadata = os.fstat(descriptor)
if not stat.S_ISREG(metadata.st_mode):
return None, True
# Sniff a small prefix first: files that are clearly compiled
# binaries (executable magic, or NUL bytes in the head) are never
# shell scripts, so skip them WITHOUT reading the rest — reading a
# megabyte of machine code just to discard it wastes the guard's
# budget and (pre-#77703) fed decoded garbage into the recursion.
data = os.read(descriptor, _BINARY_SNIFF_BYTES)
if data.startswith(_BINARY_MAGIC_PREFIXES) or b"\x00" in data:
return None, False
# Read the remainder (bounded). Loop because os.read may return
# short for non-regular-file-backed descriptors.
while len(data) <= _MAX_REFERENCED_SCRIPT_BYTES:
chunk = os.read(
descriptor, _MAX_REFERENCED_SCRIPT_BYTES + 1 - len(data)
)
if not chunk:
break
data += chunk
except OSError:
return None, False
finally:
os.close(descriptor)
# A NUL byte in the first chunk means this is a binary (ELF/Mach-O/
# PE), not a shell script — scanning its decoded contents would
# tokenize machine code and feed junk paths into the recursion
# (including a `ValueError: embedded null byte` from Path.resolve,
# #76762). Treat it as "nothing to scan" rather than unsafe: a binary
# executed by the user is not a referenced *shell script*.
if b"\x00" in data:
return None, False
if len(data) > _MAX_REFERENCED_SCRIPT_BYTES:
return None, True
return data.decode("utf-8", errors="replace"), False
def _sanitize_remote_script_text(text: Optional[str]) -> tuple[Optional[str], bool]:
"""Apply the local-read contract to text from a ``read_remote_script`` callback.
The recursion boundary must not trust its callbacks: any backend (SSH,
Modal, Daytona, or a future one) can hand back raw binary bytes decoded
as text, or arbitrarily large output. Mirror
``_read_referenced_script``'s semantics exactly — NUL bytes mean binary
(nothing to scan, checked first, #77703), oversized text fails closed
like an oversized local file (#76762) — so remote and local reads can
never diverge again. The size check re-encodes to compare *bytes*
(matching the local read and the ``head -c`` wire bound): a >1 MiB
multibyte file truncated at the byte cap decodes to fewer characters
than bytes, and a character-count check would scan the truncated text
instead of failing closed. Enforced here rather than inside each
callback so the guarantee holds for every callback, not just the ones
we hardened.
"""
if not text:
return None, False
if "\x00" in text:
return None, False
if len(text.encode("utf-8", errors="replace")) > _MAX_REFERENCED_SCRIPT_BYTES:
return None, True
return text, False
def _contains_unsafe_gateway_action(
command: str,
*,
cwd: Optional[str],
depth: int,
visited: set[Path],
read_remote_script: Optional[_ReadRemoteScriptFn] = None,
) -> bool:
if _direct_lifecycle_scan(command):
return True
if depth >= _MAX_REFERENCED_SCRIPT_DEPTH:
return True
for payload in _iter_shell_command_payloads(command):
if _contains_unsafe_gateway_action(
payload,
cwd=cwd,
depth=depth + 1,
visited=visited,
read_remote_script=read_remote_script,
):
return True
for script_path in _iter_referenced_shell_scripts(command, cwd=cwd):
try:
resolved = script_path.resolve(strict=False)
except (OSError, ValueError):
# OSError: unreadable/long paths. ValueError: embedded NUL byte
# from a binary's decoded contents tokenized as a path — a
# guarded path must never crash the guard (#76762).
resolved = script_path
if resolved in visited:
continue
visited.add(resolved)
script_text, unsafe = _read_referenced_script(script_path)
if unsafe:
return True
if script_text is None and read_remote_script is not None:
# Local path missing; try the remote backend if one is available.
# The callback's output crosses the same trust boundary as a
# local read — sanitize it identically before it enters the
# recursion (binary skip + size fail-closed).
script_text, unsafe = _sanitize_remote_script_text(
read_remote_script(str(script_path))
)
if unsafe:
return True
if not script_text:
continue
# Relative references inside a script resolve against that script's
# directory, not the original command's cwd.
script_dir = _resolve_script_directory(str(resolved)) or cwd
if _contains_unsafe_gateway_action(
script_text,
cwd=script_dir,
depth=depth + 1,
visited=visited,
read_remote_script=read_remote_script,
):
return True
return False
def contains_gateway_lifecycle_command_or_referenced_script(
command: str,
*,
cwd: Optional[str] = None,
read_remote_script: Optional[_ReadRemoteScriptFn] = None,
) -> bool:
"""Detect lifecycle/submit commands, including bounded nested scripts.
Total by construction: this function returns a verdict for *every*
input and never raises. The direct scans below are pure string
operations; the referenced-script walk touches the filesystem, remote
backends, and shlex on arbitrary decoded bytes, so it is best-effort
defense-in-depth — any unexpected failure inside it is logged and
treated as "walk found nothing" rather than crashing the caller.
This is the contract #76762 established ("a guarded path must never
crash the guard") enforced at the boundary instead of per-syscall: a
guard crash propagates out of ``tools/terminal_tool.py`` and breaks
every terminal command until the gateway restarts (#77780, #78256),
which is strictly worse than either verdict.
"""
try:
# Includes the direct regex/submit scans at depth 0.
return _contains_unsafe_gateway_action(
command,
cwd=cwd,
depth=0,
visited=set(),
read_remote_script=read_remote_script,
)
except Exception:
logger.warning(
"lifecycle guard referenced-script walk failed; "
"falling back to direct-scan verdict",
exc_info=True,
)
# Pure string scans of the top-level command — cannot raise.
try:
return _direct_lifecycle_scan(command)
except Exception:
# The data-argument masker tokenizes arbitrary text; if even
# that fails, fall to the raw regex + submit scan so the guard
# stays total.
return contains_gateway_lifecycle_command(
command
) or contains_launchctl_submit_command(command)
def _resolve_script_path(script_path: str) -> Optional[Path]:
"""Resolve a cron ``script`` value the same way the scheduler does.
The scheduler (``cron.scheduler``) resolves a bare/relative script path
under ``<HERMES_HOME>/scripts/`` and only accepts absolute paths as-is.
We MUST mirror that here so the guard scans the file that will actually
run — otherwise a job whose script lives at the scheduler's real location
(``~/.hermes/scripts/restart.sh``) but is passed as the bare name
``restart.sh`` would read as a nonexistent relative path and silently
scan prompt-only content, letting the command through.
Returns ``None`` for values that cannot be a real path (NUL bytes,
unexpandable ``~``) — the same ingestion contract as
``_expand_candidate_path``; such a value can never name a file the
scheduler would execute, so there is nothing to scan.
"""
from hermes_constants import get_hermes_home
raw = _expand_candidate_path(script_path)
if raw is None:
return None
if raw.is_absolute():
return raw
try:
return get_hermes_home() / "scripts" / raw
except (RuntimeError, OSError):
# get_hermes_home() falls back to Path.home(), which raises when
# neither HERMES_HOME nor HOME is resolvable (launchd/systemd
# environments) — same ingestion contract: nothing to scan.
return None
def _read_script_for_scanning(script_path: str) -> str:
"""Read a cron script with the bounded terminal-script scanner.
Non-regular or oversized inputs fail closed by returning a lifecycle-shaped
sentinel, while missing/unreadable/unresolvable paths remain empty so
ordinary scheduler path validation can report them.
"""
resolved = _resolve_script_path(script_path)
if resolved is None:
return ""
script_text, unsafe = _read_referenced_script(resolved)
if unsafe:
return "hermes gateway restart"
return script_text or ""
def check_gateway_lifecycle(
prompt: Optional[str],
script: Optional[str] = None,
) -> None:
"""Raise ``GatewayLifecycleBlocked`` if *prompt* or *script* contains a
gateway-lifecycle command pattern.
``prompt`` is scanned directly. ``script``, when supplied, is read from
disk and concatenated for the scan. Both are considered together so a
job cannot slip through by splitting the command across the prompt and
the script.
Callers should let the exception propagate when they want the create to
fail with a ``ValueError``-shaped error (the agent's ``cronjob`` tool
surfaces this as a tool error; the CLI prints it in red and exits 1).
"""
combined = prompt or ""
python_script = False
if script:
resolved_script = _resolve_script_path(script)
python_script = resolved_script is not None and resolved_script.suffix == ".py"
script_text = _read_script_for_scanning(script)
if script_text:
combined = f"{combined}\n{script_text}"
if python_script:
# Python is executed by the interpreter, never through a POSIX
# shell: the shell-script reference walk is a false-positive
# generator on Python sources (pathlib's "/" operator resolves to
# the filesystem root and trips the regular-file check, blocking
# every innocent .py cron script, #77131). The direct command
# regex below still scans the full text, so a literal
# `hermes gateway restart` embedded in a .py script is still
# blocked. Non-regular/oversized script files still fail closed
# via the lifecycle-shaped sentinel in _read_script_for_scanning.
unsafe = _lifecycle_command_scan_with_data_exemption(combined)
else:
script_dir = _resolve_script_directory(script) if script else None
unsafe = contains_gateway_lifecycle_command_or_referenced_script(
combined,
cwd=script_dir,
)
if unsafe:
raise GatewayLifecycleBlocked(
"Blocked: cron job contains a gateway lifecycle command or persistent "
"launchctl submit operation. This is blocked to prevent agent-driven "
"SIGTERM-respawn loops under launchd/systemd supervision "
"(#30719). Run `hermes gateway restart` from a shell outside "
"the running gateway instead."
)