1415 lines
62 KiB
Python
1415 lines
62 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|uninstall`` 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.
|
|
|
|
The profile-flag form (``hermes -p <profile> gateway restart|stop``, #78028)
|
|
is handled profile-aware: it is blocked only when the named profile is the
|
|
profile running the guard. Sibling-profile restarts are legitimate fleet
|
|
operations and stay allowed.
|
|
"""
|
|
|
|
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: destructive `hermes gateway` operations.
|
|
# The destructive operations are restart, stop, and uninstall.
|
|
# `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.
|
|
# The lookbehind (#77173): `hermes` must not be a path component or a
|
|
# word tail. Excluding `/`, word chars, `.` and `-` keeps file paths
|
|
# with embedded spaces (`/docs/hermes gateway restart-notes.md`) from
|
|
# matching via the `/hermes` tail, while every real command position
|
|
# (start of text, whitespace, `;`/`&`/`|`, `$(`, backtick, even a
|
|
# U+FFFD from binary-content decoding) still matches.
|
|
r"(?:(?<![/\w.\-])hermes\s+gateway\s+(?:restart|stop|uninstall)\b)"
|
|
# 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).
|
|
# `bootout`/`remove`/`disable` sit alongside `unload`: Apple deprecated
|
|
# load/unload in favour of bootstrap/bootout, so `bootout` is the modern
|
|
# spelling of an already-listed verb, `remove` is its legacy sibling, and
|
|
# `disable` is what makes an unload durable across boots. Omitting them
|
|
# left the bypassable approval layer (tools/approval.py, skipped on
|
|
# force=True) as the only cover, while this hard block — documented as
|
|
# "force=True cannot help here" — let them through (#80260).
|
|
r"|(?:launchctl\s+(?:kickstart|unload|load|stop|restart|submit|bootstrap|bootout|remove|disable)\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.
|
|
# Leading \b ensures we match "pkill" or "kill" as whole words, not as
|
|
# suffixes of other words (e.g. "skill" -> "kill").
|
|
r"|(?:\bp?kill\b[^\n]*\bhermes\b[^\n]*\bgateway)"
|
|
r"|(?:\bp?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]*")
|
|
|
|
# Python argv-list punctuation (#68289): `subprocess.run(["launchctl",
|
|
# "bootout", ...])` separates the words the OS will exec with brackets and
|
|
# commas rather than spaces. Stripped before the token-join re-scan only —
|
|
# never from the raw text, so prose stays governed by the primary pattern.
|
|
_ARGV_LIST_PUNCTUATION = re.compile(r"[\[\],]+")
|
|
|
|
|
|
# Branch A2 (#78028): the same foot-gun written with an explicit profile
|
|
# selector — `hermes -p <profile> gateway restart|stop` / `--profile <name>`
|
|
# / `--profile=<name>`. The selector token between `hermes` and `gateway`
|
|
# breaks Branch A's literal adjacency. Unlike Branch A this form is NOT
|
|
# unconditionally self-targeting: issued from inside gateway `zeus`,
|
|
# `hermes -p venus gateway restart` operates on a sibling profile's gateway
|
|
# and is a legitimate fleet operation. The pattern captures the named
|
|
# profile so `contains_gateway_lifecycle_command` can block only the
|
|
# self-targeting shape (named profile == the profile running the guard).
|
|
# `start` stays excluded for the same reason as Branch A.
|
|
_PROFILE_FLAG_LIFECYCLE_PATTERN = re.compile(
|
|
r"(?i)"
|
|
r"hermes\s+"
|
|
# Any global flags before the profile selector (each may carry a value).
|
|
r"(?:-{1,2}\S+(?:\s+\S+)?\s+)*"
|
|
# The selector itself: `--profile=<name>` or the space-separated
|
|
# `-p <name>` / `--profile <name>` — exactly the shapes the CLI's
|
|
# `_apply_profile_override` accepts.
|
|
r"(?:--profile=([^\s]+)|(?:-p|--profile)\s+([^\s]+))"
|
|
# Any global flags between the selector and the subcommand.
|
|
r"(?:\s+-{1,2}\S+(?:\s+\S+)?)*"
|
|
r"\s+gateway\s+(?:restart|stop)"
|
|
)
|
|
|
|
|
|
def _current_profile_name() -> Optional[str]:
|
|
"""Return the name of the profile running the guard, if determinable.
|
|
|
|
Prefers the explicit ``HERMES_PROFILE_NAME`` / ``HERMES_PROFILE`` env
|
|
(set by the profile launcher and kanban worker spawns), falling back to
|
|
``hermes_cli.profiles.get_active_profile_name`` (derived from
|
|
``HERMES_HOME``, which the gateway process inherits from its launch
|
|
profile). Returns ``None`` when neither source yields a name.
|
|
"""
|
|
for env_name in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
|
|
value = os.environ.get(env_name)
|
|
if value and value.strip():
|
|
return value.strip()
|
|
try:
|
|
from hermes_cli.profiles import get_active_profile_name
|
|
|
|
return get_active_profile_name() or None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _named_profile_is_current(named: str) -> bool:
|
|
"""True when *named* is the profile executing the guard (self-targeting)."""
|
|
current = _current_profile_name()
|
|
if not current:
|
|
# No profile identity available: cannot prove self-targeting, so do
|
|
# not block — sibling restarts must stay allowed (#78028).
|
|
return False
|
|
return named.strip().casefold() == current.strip().casefold()
|
|
|
|
|
|
# Branch B only catches `launchctl <verb> ... hermes[.-]?gateway` when the
|
|
# label literally appears AFTER the verb in the same `[^\n]*` span, and its
|
|
# verb list is missing `bootout`/`kill`/`disable`/`remove` entirely (2026-08-02
|
|
# incident). `bootout` is the one that actually unloads a job's registration
|
|
# — worse than `stop`/`kickstart`, which just bounce a still-registered job.
|
|
#
|
|
# A shell loop that builds the label from a list defined EARLIER in the same
|
|
# command — `for item in 'ai.hermes.gateway-apollo:...' 'ai.hermes.gateway:...';
|
|
# do label=${item%%:*}; launchctl bootout "gui/$uid/$label"; done` — puts the
|
|
# literal label text in a different `;`-separated segment than the verb, so
|
|
# no amount of same-segment tokenization sees it: the token next to `bootout`
|
|
# is the unexpanded variable `$label`, not the string "hermes.gateway". This
|
|
# incident command evaded Branch B on both counts (missing verb AND order)
|
|
# and unloaded all 4 profiles' launchd jobs with zero approval.
|
|
#
|
|
# Unlike `submit`/`bootstrap` (handled separately, fully label-independent,
|
|
# because a NEW job's label is attacker-chosen), these verbs act on an
|
|
# EXISTING job, so anchoring to the hermes-gateway label is still correct —
|
|
# `test_safe_commands` requires unrelated-label ops (e.g. `launchctl unload
|
|
# ai.hermes.update-checker.plist`) to stay unblocked. The fix is checking
|
|
# "verb anywhere AND label anywhere", not "label right after verb".
|
|
_LAUNCHCTL_LIFECYCLE_VERBS_RE = re.compile(
|
|
r"(?i)\blaunchctl\s+(?:kickstart|unload|load|stop|restart|bootout|kill|disable|remove)\b"
|
|
)
|
|
_HERMES_GATEWAY_LABEL_RE = re.compile(r"(?i)\bhermes[.\-]?gateway\b")
|
|
|
|
|
|
def _contains_launchctl_gateway_lifecycle(normalized_text: str) -> bool:
|
|
"""Order-independent companion to Branch B — see comment above."""
|
|
return bool(_LAUNCHCTL_LIFECYCLE_VERBS_RE.search(normalized_text)) and bool(
|
|
_HERMES_GATEWAY_LABEL_RE.search(normalized_text)
|
|
)
|
|
|
|
|
|
def contains_gateway_lifecycle_command(text: str) -> bool:
|
|
"""Return True if *text* contains a gateway lifecycle command pattern.
|
|
|
|
Matches in two passes. The first is the raw-text regex above — cheap,
|
|
and the only pass that can fire on non-shell inputs shlex can't
|
|
tokenize (e.g. a Python source string). The second re-runs the same
|
|
pattern against each command segment after shell tokenization, where
|
|
quotes and backslash escapes have already been resolved.
|
|
|
|
That second pass exists because a real shell resolves quote-splicing
|
|
(``kick"start"``) and backslash-escaping (``kick\\start``) into one
|
|
literal word — ``kickstart`` — before the command ever runs. The raw
|
|
text still has the quote or backslash sitting between the verb's two
|
|
halves, so the first pass alone lets a spliced verb reach
|
|
``launchctl``/``systemctl`` untouched while still executing as the
|
|
blocked lifecycle command (#80269, reported against #80260's bootout
|
|
parity fix). Tokenizing closes that gap while keeping the same
|
|
gateway-label anchoring (``_GATEWAY_LIFECYCLE_PATTERN`` still requires
|
|
a ``hermes``/``gateway`` token) — this function is the single choke
|
|
point ``_contains_unsafe_gateway_action`` calls at every recursion
|
|
level, so referenced-script and ``sh -c`` payload scanning inherit the
|
|
fix automatically.
|
|
"""
|
|
if not text:
|
|
return False
|
|
# Heredoc bodies that are provably inert data (quoted delimiter, data-sink
|
|
# consumer like `cat > file <<'EOF'`) are masked before scanning (#88336):
|
|
# a runbook line "a human can run: hermes gateway restart" inside such a
|
|
# body is documentation, not a command this shell will execute. The
|
|
# stripper fails open on ANY ambiguity (unquoted delimiter, shell
|
|
# consumer, unterminated body), so executable heredocs are still scanned.
|
|
from tools.shell_heredoc import strip_inert_heredoc_bodies
|
|
|
|
text = strip_inert_heredoc_bodies(text)
|
|
normalized = _SHELL_LINE_CONTINUATION.sub(" ", text)
|
|
if _GATEWAY_LIFECYCLE_PATTERN.search(normalized):
|
|
return True
|
|
# Profile-flag form (#78028): `hermes -p <profile> gateway restart|stop`
|
|
# bypasses Branch A because the selector sits between `hermes` and
|
|
# `gateway`. It is only the same foot-gun when the named profile IS the
|
|
# profile running the guard — sibling-profile restarts are legitimate
|
|
# fleet operations and stay allowed.
|
|
profile_match = _PROFILE_FLAG_LIFECYCLE_PATTERN.search(normalized)
|
|
if profile_match:
|
|
named = profile_match.group(1) or profile_match.group(2)
|
|
if named:
|
|
# Profile ids cannot contain quotes (hermes_cli.profiles
|
|
# enforces `^[a-z0-9][a-z0-9_-]{0,63}$`), so a shell-quoted
|
|
# `-p 'zeus'` compares equal to the bare name.
|
|
named = named.strip().strip("\"'")
|
|
if _named_profile_is_current(named):
|
|
return True
|
|
# Token-aware second pass (#80269): re-run the pattern on shell-tokenized
|
|
# segments where quotes/escapes are resolved, closing splice bypasses
|
|
# like `kick"start"`. Runs after the profile-flag check so both passes
|
|
# apply independently. Tokens are additionally re-joined with Python
|
|
# argv-list punctuation ([ ] ,) stripped (#68289): the same command
|
|
# reaches this guard as `subprocess.run(["launchctl", "bootout", ...])`
|
|
# from execute_code, where commas and brackets — not spaces — separate
|
|
# the argv words the OS will actually see.
|
|
for segment in _iter_command_segments(normalized):
|
|
joined = " ".join(segment)
|
|
if joined and _GATEWAY_LIFECYCLE_PATTERN.search(joined):
|
|
return True
|
|
stripped = _ARGV_LIST_PUNCTUATION.sub(" ", joined)
|
|
if stripped != joined and _GATEWAY_LIFECYCLE_PATTERN.search(stripped):
|
|
return True
|
|
# Order-independent launchctl pass (#77083): a shell loop can build the
|
|
# gateway label from a variable defined in an earlier `;`-separated
|
|
# segment (`label=${item%%:*}; launchctl bootout "gui/$uid/$label"`), so
|
|
# neither the same-span regex nor same-segment tokenization sees verb
|
|
# and label together. Check "verb anywhere AND label anywhere" instead.
|
|
return _contains_launchctl_gateway_lifecycle(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
|
|
# Whole-walk work limits (#78398). The per-file cap and depth bound above
|
|
# limit one read, not the walk: a command can reference arbitrarily many
|
|
# scripts, and the pure-Python shlex pass (one lexer per line, quadratic on a
|
|
# giant token) once held the GIL for minutes on a broad command. These caps
|
|
# bound one whole walk and are charged BEFORE any text reaches shlex.
|
|
# Exhaustion fails closed (an unscanned script could hide a lifecycle command)
|
|
# and is logged at WARNING so an operator can tell it from a real block. Sizes
|
|
# sit well above any legitimate wrapper graph (a 200-script wrapper is ~5 KB)
|
|
# while keeping the worst text still admitted to a few seconds of lexing;
|
|
# remote reads are a backend roundtrip each, so they get a far tighter cap.
|
|
_MAX_LIFECYCLE_SCAN_BYTES = _MAX_REFERENCED_SCRIPT_BYTES # 1 MiB across the walk
|
|
_MAX_LIFECYCLE_SCAN_LINES = 16384
|
|
_MAX_LIFECYCLE_SCAN_LINE_BYTES = 64 * 1024
|
|
_MAX_LIFECYCLE_SCAN_PATHS = 1024
|
|
_MAX_LIFECYCLE_SCAN_REMOTE_READS = 64
|
|
_CONTROL_CHARS = frozenset(";&|()")
|
|
|
|
|
|
class _LifecycleScanBudget:
|
|
"""Shared work budget for one complete referenced-script walk."""
|
|
|
|
__slots__ = (
|
|
"bytes_remaining",
|
|
"lines_remaining",
|
|
"paths_remaining",
|
|
"remote_reads_remaining",
|
|
)
|
|
|
|
def __init__(self) -> None:
|
|
# Read the module constants at construction so tests (and operators)
|
|
# can lower them without defaults capturing stale values at import.
|
|
self.bytes_remaining = _MAX_LIFECYCLE_SCAN_BYTES
|
|
self.lines_remaining = _MAX_LIFECYCLE_SCAN_LINES
|
|
self.paths_remaining = _MAX_LIFECYCLE_SCAN_PATHS
|
|
self.remote_reads_remaining = _MAX_LIFECYCLE_SCAN_REMOTE_READS
|
|
|
|
def charge_text(self, text: str) -> bool:
|
|
"""Charge *text* before tokenization; False when it does not fit."""
|
|
# UTF-8 is at least one byte per code point, so the character count
|
|
# is a free lower bound — skip the encode for obviously-oversized input.
|
|
if len(text) > self.bytes_remaining:
|
|
return False
|
|
encoded = len(text.encode("utf-8", errors="replace"))
|
|
if encoded > self.bytes_remaining:
|
|
return False
|
|
lines = text.count("\n") + 1
|
|
if lines > self.lines_remaining:
|
|
return False
|
|
# One huge token is the quadratic shlex case; bound the longest
|
|
# physical line. Measured in characters (a lower bound on bytes) —
|
|
# tight enough for a DoS bound without a per-line encode.
|
|
longest = max((len(line) for line in text.split("\n")), default=0)
|
|
if longest > _MAX_LIFECYCLE_SCAN_LINE_BYTES:
|
|
return False
|
|
self.bytes_remaining -= encoded
|
|
self.lines_remaining -= lines
|
|
return True
|
|
|
|
def charge_path(self) -> bool:
|
|
"""Charge one unique referenced path before any local/remote read."""
|
|
if self.paths_remaining <= 0:
|
|
return False
|
|
self.paths_remaining -= 1
|
|
return True
|
|
|
|
def charge_remote_read(self) -> bool:
|
|
"""Charge one remote-backend read (a network roundtrip each)."""
|
|
if self.remote_reads_remaining <= 0:
|
|
return False
|
|
self.remote_reads_remaining -= 1
|
|
return True
|
|
|
|
|
|
def _capped_read_limit(max_bytes: Optional[int]) -> int:
|
|
"""Per-read byte cap: never above the per-file cap, never negative.
|
|
|
|
One definition so local and remote reads cannot diverge again (#76762,
|
|
#77703 were exactly that class of bug).
|
|
"""
|
|
if max_bytes is None:
|
|
return _MAX_REFERENCED_SCRIPT_BYTES
|
|
return min(_MAX_REFERENCED_SCRIPT_BYTES, max(0, int(max_bytes)))
|
|
|
|
|
|
def lifecycle_scan_root_within_budget(text: str) -> bool:
|
|
"""Whether *text* may safely enter an optional tokenizer pass.
|
|
|
|
Used by ``tools/terminal_tool.py`` to gate its launchctl-specific pre-scan
|
|
(which tokenizes with shlex). This is a FRESH budget, independent of the
|
|
one the full guard builds for its own walk: the pre-scan may pass while
|
|
the guard's walk later exhausts, and the outcome is still fail-closed —
|
|
only the friendlier launchctl diagnostic is lost. ``False`` is not a
|
|
verdict: callers must still run the full guard, which fails closed for
|
|
an over-budget root.
|
|
"""
|
|
try:
|
|
return _LifecycleScanBudget().charge_text(text)
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
# Directory names that sit directly under a `Library` path component and
|
|
# mark a FileProvider-backed subtree: `Mobile Documents` is iCloud Drive;
|
|
# `CloudStorage` hosts every third-party FileProvider domain (Dropbox,
|
|
# OneDrive, Google Drive, Box, ...) on modern macOS.
|
|
_CLOUD_PLACEHOLDER_MARKERS = frozenset({"Mobile Documents", "CloudStorage"})
|
|
|
|
|
|
def _is_cloud_placeholder_path(path: Path) -> bool:
|
|
"""Return True for paths inside a macOS FileProvider-backed subtree.
|
|
|
|
``O_NONBLOCK`` does not make regular-file reads non-blocking. Opening an
|
|
evicted FileProvider placeholder below ``~/Library/Mobile Documents``
|
|
(iCloud Drive) or ``~/Library/CloudStorage`` (Dropbox / OneDrive /
|
|
Google Drive and other third-party providers) can therefore wait
|
|
indefinitely for hydration. The lifecycle guard runs before a terminal
|
|
command's timeout starts, so it must identify this boundary from path
|
|
metadata and fail closed without opening the file.
|
|
"""
|
|
parts = path.parts
|
|
return any(
|
|
parts[index - 1] == "Library" and part in _CLOUD_PLACEHOLDER_MARKERS
|
|
for index, part in enumerate(parts)
|
|
if index
|
|
)
|
|
|
|
# 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 leading dot also disables masking, because sqlite3 spells its escapes as
|
|
# dot-commands (`.shell`, `.system`, `.import`). But `.`, `./x` and `../x`
|
|
# are ordinary path operands, and `grep -r <pattern> .` is a far more common
|
|
# shape than any dot-command — treating those as escapes disabled the
|
|
# exemption for the single most ordinary way to run a recursive search,
|
|
# blocking `grep -r 'systemctl restart hermes-gateway' .` outright. Require a
|
|
# dot followed by a NAME character so a relative path stays a path.
|
|
_DOT_COMMAND_ARGUMENT = re.compile(r"^\.[A-Za-z]")
|
|
# 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 _split_logical_lines(text: str) -> list[str]:
|
|
"""Split text on newlines that are not inside quotes.
|
|
|
|
A newline inside a quoted string (single or double quotes) is data,
|
|
not a command separator. Handles escaped quotes within strings.
|
|
"""
|
|
lines = []
|
|
current = []
|
|
in_single = False
|
|
in_double = False
|
|
escape = False
|
|
|
|
for ch in text:
|
|
if escape:
|
|
current.append(ch)
|
|
escape = False
|
|
continue
|
|
if ch == "\\":
|
|
escape = True
|
|
current.append(ch)
|
|
continue
|
|
if ch == "'" and not in_double:
|
|
in_single = not in_single
|
|
current.append(ch)
|
|
continue
|
|
if ch == '"' and not in_single:
|
|
in_double = not in_double
|
|
current.append(ch)
|
|
continue
|
|
if ch == "\n" and not in_single and not in_double:
|
|
lines.append("".join(current))
|
|
current = []
|
|
continue
|
|
current.append(ch)
|
|
|
|
if current:
|
|
lines.append("".join(current))
|
|
return lines
|
|
|
|
|
|
def _iter_command_segments(command: str) -> Iterator[list[str]]:
|
|
"""Yield shell-tokenized command segments, honoring quotes and comments.
|
|
|
|
A newline inside a quoted token is data, not a command separator.
|
|
First split on logical lines (newlines outside quotes), then tokenize
|
|
each logical line with shlex. If a logical line cannot be tokenized
|
|
(unbalanced quotes), fall back to per-physical-line tokenization for
|
|
that logical line.
|
|
"""
|
|
normalized = command.replace("\\\n", "")
|
|
logical_lines = _split_logical_lines(normalized)
|
|
|
|
for line in logical_lines:
|
|
# Try to tokenize the logical line as a whole.
|
|
try:
|
|
lexer = shlex.shlex(
|
|
line,
|
|
posix=True,
|
|
punctuation_chars=";&|()",
|
|
)
|
|
lexer.whitespace_split = True
|
|
lexer.commenters = "#"
|
|
tokens = list(lexer)
|
|
except ValueError:
|
|
# Fall back to per-physical-line tokenization for this logical line.
|
|
# This handles cases where quotes are unbalanced across lines.
|
|
for physical_line in line.splitlines():
|
|
try:
|
|
lexer = shlex.shlex(
|
|
physical_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
|
|
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 _executable_name(token: str) -> str:
|
|
"""Return the command name for a tokenized executable token.
|
|
|
|
``Path(token).name`` is right for real paths (``/usr/bin/bash`` →
|
|
``bash``), but pathlib has no name component for the pure-path tokens
|
|
``.``, ``..`` and ``/``, so it returns "" for them. The POSIX
|
|
dot-source builtin is spelled ``.``, so keying the sourced-script
|
|
branch on ``Path(token).name`` alone made it unreachable: ``source
|
|
./helper.sh`` was scanned but its exact synonym ``. ./helper.sh`` was
|
|
not, letting a referenced script carrying a lifecycle command through
|
|
both the cron guard and the in-gateway terminal guard. Fall back to the
|
|
raw token so ``.`` survives.
|
|
"""
|
|
return Path(token).name or token
|
|
|
|
|
|
# Prefixes that hand execution straight to their argument tail: the command
|
|
# that actually runs sits further right. A guard that reads only the first
|
|
# token sees `sudo`/`env`/`nohup` and never inspects what they run, so
|
|
# `sudo bash ~/restart.sh` walked past the same walk that stops
|
|
# `bash ~/restart.sh`, and `sudo launchctl submit ...` past the
|
|
# label-independent submit block (#62891). `_PIPE_TO_INTERPRETER` above
|
|
# already reads `sudo ` this way for the pipe case; this generalises that
|
|
# reading to the command position.
|
|
_TRANSPARENT_COMMAND_PREFIXES = frozenset({
|
|
"sudo", "doas", "env", "nohup", "setsid", "nice", "ionice", "stdbuf",
|
|
"timeout", "exec", "command", "builtin", "eatmydata",
|
|
# Privilege and namespace wrappers. Same shape — options, then the
|
|
# command they hand execution to.
|
|
"pkexec", "su", "runuser", "setpriv", "systemd-run", "nsenter", "unshare",
|
|
})
|
|
|
|
# Options of those wrappers that consume the NEXT token as their value, so a
|
|
# value is never mistaken for the wrapped command (`sudo -u deploy bash x.sh`).
|
|
_TRANSPARENT_PREFIX_VALUE_OPTIONS = {
|
|
"sudo": {"-u", "-g", "-U", "-C", "-p", "-r", "-t", "-T",
|
|
"--user", "--group", "--prompt"},
|
|
"doas": {"-u", "-C"},
|
|
"env": {"-u", "--unset", "-S", "--split-string", "-C", "--chdir"},
|
|
"nice": {"-n", "--adjustment"},
|
|
"ionice": {"-c", "-n", "-p", "--class", "--classdata"},
|
|
"stdbuf": {"-i", "-o", "-e", "--input", "--output", "--error"},
|
|
"timeout": {"-s", "-k", "--signal", "--kill-after"},
|
|
"pkexec": {"--user"},
|
|
"su": {"-s", "--shell", "-g", "--group", "-G", "--supp-group"},
|
|
"runuser": {"-u", "--user", "-s", "--shell", "-g", "--group",
|
|
"-G", "--supp-group"},
|
|
"setpriv": {"--reuid", "--regid", "--groups", "--inh-caps",
|
|
"--ambient-caps", "--bounding-set", "--selinux-label",
|
|
"--apparmor-profile"},
|
|
"systemd-run": {"-u", "--unit", "-p", "--property", "-E", "--setenv",
|
|
"--slice", "--description", "--uid", "--gid",
|
|
"--on-calendar", "--service-type"},
|
|
"nsenter": {"-t", "--target", "-S", "--setuid", "-G", "--setgid",
|
|
"-r", "--root", "-w", "--wd"},
|
|
"unshare": {"--map-user", "--map-group", "--setgroups", "-R", "--root",
|
|
"-w", "--wd"},
|
|
}
|
|
|
|
# Wrappers whose option carries a COMMAND STRING rather than an argv tail.
|
|
# The string is shell source and must be re-scanned like `sh -c` — skipping
|
|
# it as an opaque option value would hide whatever it runs
|
|
# (`env -S 'bash ~/restart.sh'`).
|
|
_STRING_COMMAND_OPTIONS = {
|
|
"env": ("-S", "--split-string"),
|
|
"su": ("-c", "--command"),
|
|
"runuser": ("-c", "--command"),
|
|
}
|
|
|
|
# Wrappers whose first non-option operand is a VALUE, not the command
|
|
# (`timeout 60 bash x.sh`).
|
|
_TRANSPARENT_PREFIX_OPERANDS = {"timeout": 1}
|
|
|
|
_ENV_ASSIGNMENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*=")
|
|
|
|
# Bound the walk: a pathological token run must not spin here.
|
|
_MAX_PREFIX_PEELS = 8
|
|
|
|
|
|
def _peel_transparent_prefixes(segment: list[str], index: int) -> int:
|
|
"""Return the index of the command a wrapper chain actually executes.
|
|
|
|
Returns *index* unchanged when the token there is not a wrapper, and may
|
|
return ``len(segment)`` when a wrapper has no operand — callers must
|
|
bounds-check before indexing.
|
|
"""
|
|
for _ in range(_MAX_PREFIX_PEELS):
|
|
if index >= len(segment):
|
|
return index
|
|
name = _executable_name(segment[index])
|
|
if name not in _TRANSPARENT_COMMAND_PREFIXES:
|
|
return index
|
|
value_options = _TRANSPARENT_PREFIX_VALUE_OPTIONS.get(name, frozenset())
|
|
index += 1
|
|
while index < len(segment):
|
|
token = segment[index]
|
|
if token == "--":
|
|
# POSIX end-of-options: the command starts at the next token.
|
|
index += 1
|
|
break
|
|
if token in value_options:
|
|
index += 2
|
|
continue
|
|
if token.startswith("-") or _ENV_ASSIGNMENT.match(token):
|
|
index += 1
|
|
continue
|
|
break
|
|
for _ in range(_TRANSPARENT_PREFIX_OPERANDS.get(name, 0)):
|
|
if index < len(segment) and not segment[index].startswith("-"):
|
|
index += 1
|
|
return index
|
|
|
|
|
|
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 _ENV_ASSIGNMENT.match(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
|
|
index = _peel_transparent_prefixes(segment, index)
|
|
if index >= len(segment):
|
|
continue
|
|
if _executable_name(segment[index]) == "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(
|
|
_DOT_COMMAND_ARGUMENT.match(argument)
|
|
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_option_values(
|
|
segment: list[str], start: int, option: str
|
|
) -> Iterator[str]:
|
|
"""Yield values given to *option*, in both ``--opt v`` and ``--opt=v`` form."""
|
|
prefix = option + "="
|
|
for position in range(start + 1, len(segment)):
|
|
token = segment[position]
|
|
if token == option and position + 1 < len(segment):
|
|
yield segment[position + 1]
|
|
elif token.startswith(prefix):
|
|
yield token[len(prefix):]
|
|
|
|
|
|
def _references_at(
|
|
segment: list[str], index: int, cwd: Optional[str]
|
|
) -> Iterator[Path]:
|
|
"""Yield the scripts the token at *index* executes, if any."""
|
|
if index >= len(segment):
|
|
return
|
|
executable = segment[index]
|
|
executable_name = _executable_name(executable)
|
|
|
|
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
|
|
return
|
|
|
|
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
|
|
return
|
|
|
|
# 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_referenced_shell_scripts(
|
|
command: str,
|
|
*,
|
|
cwd: Optional[str] = None,
|
|
) -> Iterator[Path]:
|
|
"""Yield scripts executed directly or through a POSIX shell.
|
|
|
|
Each segment is read twice: once at the token the walk has always used,
|
|
and again at the command a wrapper chain hands off to. Additive on
|
|
purpose — peeling must never REMOVE a reference the un-peeled read would
|
|
have found. A local script named ``./timeout`` is a script, not the
|
|
coreutils wrapper, and reading only the peeled index would skip it.
|
|
"""
|
|
for segment in _iter_command_segments(command):
|
|
index = _command_token_index(segment)
|
|
if index is None:
|
|
continue
|
|
yield from _references_at(segment, index, cwd)
|
|
peeled = _peel_transparent_prefixes(segment, index)
|
|
if peeled != index:
|
|
yield from _references_at(segment, peeled, cwd)
|
|
|
|
|
|
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:
|
|
continue
|
|
# Command-string options are read at the ORIGINAL token: peeling past
|
|
# `su`/`env` would discard the very option carrying the command.
|
|
for option in _STRING_COMMAND_OPTIONS.get(
|
|
_executable_name(segment[index]), ()
|
|
):
|
|
yield from _iter_option_values(segment, index, option)
|
|
index = _peel_transparent_prefixes(segment, index)
|
|
if index >= len(segment):
|
|
continue
|
|
if _executable_name(segment[index]) 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
|
|
|
|
|
|
_BINARY_MAGICS = (
|
|
b"\x7fELF", # ELF — Linux/BSD executables and shared objects
|
|
b"\xfe\xed\xfa\xce", # Mach-O 32-bit
|
|
b"\xfe\xed\xfa\xcf", # Mach-O 64-bit
|
|
b"\xce\xfa\xed\xfe", # Mach-O 32-bit, byte-swapped
|
|
b"\xcf\xfa\xed\xfe", # Mach-O 64-bit, byte-swapped
|
|
b"\xca\xfe\xba\xbe", # Mach-O universal ("fat") binary
|
|
b"MZ", # PE/COFF — Windows .exe/.dll
|
|
b"!<arch>", # static archive (.a)
|
|
b"\x1f\x8b", # gzip
|
|
b"PK\x03\x04", # zip (also .jar/.whl/.egg)
|
|
)
|
|
|
|
|
|
def _has_binary_magic(data: bytes) -> bool:
|
|
"""Return True when *data* starts with a known compiled-binary signature.
|
|
|
|
Deliberately narrower than "contains a NUL byte": a shell script that
|
|
happens to hold a NUL is still executed by ``bash``, so treating every
|
|
NUL-bearing file as an unscannable binary lets a padded script bypass the
|
|
lifecycle scan entirely.
|
|
|
|
A shebang always wins — an interpreted script is never a binary, however
|
|
odd its payload. File extensions are deliberately *not* consulted: a
|
|
suffixless shell script must still be scanned (and, if oversized, still
|
|
fail closed).
|
|
"""
|
|
if data.startswith(b"#!"):
|
|
return False
|
|
return data.startswith(_BINARY_MAGICS)
|
|
|
|
|
|
def _read_referenced_script(
|
|
path: Path, *, max_bytes: Optional[int] = None
|
|
) -> tuple[Optional[str], bool]:
|
|
"""Return ``(text, unsafe)`` using bounded, regular-file-only reads.
|
|
|
|
``max_bytes`` lowers the per-file cap to what the calling walk can still
|
|
afford (never raises it above ``_MAX_REFERENCED_SCRIPT_BYTES``).
|
|
|
|
This is the shared choke point for every local script read the guard
|
|
performs (the terminal walk in ``_contains_unsafe_gateway_action`` AND
|
|
the cron-script scan in ``_read_script_for_scanning``), so the
|
|
cloud-placeholder refusal lives here: a FileProvider path must never be
|
|
opened — not even to discover whether the file is hydrated — because an
|
|
evicted placeholder's ``open()`` can hang preflight indefinitely
|
|
(#88052). The lexical check covers direct cloud paths; the resolved
|
|
check covers local launchers that are symlinks into a cloud subtree.
|
|
"""
|
|
byte_limit = _capped_read_limit(max_bytes)
|
|
if _is_cloud_placeholder_path(path):
|
|
return None, True
|
|
try:
|
|
resolved = 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 = path
|
|
if _is_cloud_placeholder_path(resolved):
|
|
return None, True
|
|
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):
|
|
# Directories are not scripts. Docker Desktop writes
|
|
# ``fpath=(~/.docker/completions …)`` into ``~/.zshrc``; the
|
|
# walk then treats that dir as a referenced script and used
|
|
# to fail-closed, blocking ``source ~/.zshrc`` (#86753).
|
|
# Devices/sockets stay fail-closed.
|
|
if stat.S_ISDIR(metadata.st_mode):
|
|
return None, False
|
|
return None, True
|
|
# Sniff a small prefix first: files that are clearly compiled
|
|
# binaries (executable magic) 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. Deliberately NOT keyed on the
|
|
# mere presence of a NUL byte (#77927): bash executes a text script
|
|
# straight past an embedded NUL, so NUL-bearing text must fall
|
|
# through to the magic-number check + NUL-strip below.
|
|
data = os.read(descriptor, _BINARY_SNIFF_BYTES)
|
|
if data.startswith(_BINARY_MAGIC_PREFIXES):
|
|
return None, False
|
|
# A regular file whose size already exceeds the cap fails closed
|
|
# without reading it (the walk budget can be far below 1 MiB).
|
|
if metadata.st_size > byte_limit:
|
|
return None, True
|
|
# Read the remainder (bounded). Loop because os.read may return
|
|
# short for non-regular-file-backed descriptors.
|
|
while len(data) <= byte_limit:
|
|
chunk = os.read(descriptor, byte_limit + 1 - len(data))
|
|
if not chunk:
|
|
break
|
|
data += chunk
|
|
except OSError:
|
|
return None, False
|
|
finally:
|
|
os.close(descriptor)
|
|
# Identify binaries by MAGIC NUMBER, not by the mere presence of a NUL.
|
|
#
|
|
# "contains a NUL" and "is a compiled binary" are different questions, and
|
|
# the gap between them is a guard bypass: `bash` executes a *text* script
|
|
# straight past an embedded NUL, so a single pad byte in a shell script made
|
|
# the scan skip a file that still runs its lifecycle command. Match on the
|
|
# signature instead (ELF/Mach-O/PE/static archive/compressed), and treat a
|
|
# NUL-bearing *text* file as a script whose NULs are stripped before
|
|
# scanning — stripping can only splice tokens together, never apart, so it
|
|
# fails closed.
|
|
if _has_binary_magic(data):
|
|
return None, False
|
|
# Check the size BEFORE stripping: stripping shrinks the buffer, so doing it
|
|
# first would let an oversized file slip under the threshold and skip this
|
|
# fail-closed branch.
|
|
if len(data) > byte_limit:
|
|
return None, True
|
|
if b"\x00" in data:
|
|
data = data.replace(b"\x00", b"")
|
|
return data.decode("utf-8", errors="replace"), False
|
|
|
|
|
|
def _sanitize_remote_script_text(
|
|
text: Optional[str], *, max_bytes: Optional[int] = None
|
|
) -> 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
|
|
byte_limit = _capped_read_limit(max_bytes)
|
|
if len(text) > byte_limit:
|
|
return None, True # chars <= bytes: over the cap without encoding
|
|
if len(text.encode("utf-8", errors="replace")) > byte_limit:
|
|
return None, True
|
|
return text, False
|
|
|
|
|
|
def _budget_exhausted(what: str, depth: int) -> bool:
|
|
logger.warning(
|
|
"lifecycle guard scan budget exhausted (%s at depth %d); "
|
|
"failing closed — see _MAX_LIFECYCLE_SCAN_* in cron/lifecycle_guard.py",
|
|
what, depth,
|
|
)
|
|
return True
|
|
|
|
|
|
def _contains_unsafe_gateway_action(
|
|
command: str,
|
|
*,
|
|
cwd: Optional[str],
|
|
depth: int,
|
|
visited: set[Path],
|
|
budget: _LifecycleScanBudget,
|
|
read_remote_script: Optional[_ReadRemoteScriptFn] = None,
|
|
) -> bool:
|
|
# Charge BEFORE _direct_lifecycle_scan: every scan in it (including the
|
|
# wrapper-prefix lifecycle detector) tokenizes with shlex, so checking
|
|
# afterwards would keep the CPU spike.
|
|
if not budget.charge_text(command):
|
|
return _budget_exhausted("text", depth)
|
|
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,
|
|
budget=budget,
|
|
read_remote_script=read_remote_script,
|
|
):
|
|
return True
|
|
|
|
for script_path in _iter_referenced_shell_scripts(command, cwd=cwd):
|
|
# Do not touch a FileProvider path even to discover whether the file
|
|
# is hydrated. The lexical check covers direct cloud paths; the
|
|
# resolved check below covers local launchers that are symlinks into
|
|
# a cloud subtree. _read_referenced_script repeats both checks as the
|
|
# shared choke point, so every caller stays covered even if this
|
|
# walk-level short-circuit is bypassed.
|
|
if _is_cloud_placeholder_path(script_path):
|
|
return True
|
|
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 _is_cloud_placeholder_path(resolved):
|
|
return True
|
|
if resolved in visited:
|
|
continue
|
|
if not budget.charge_path():
|
|
return _budget_exhausted("paths", depth)
|
|
visited.add(resolved)
|
|
# Never read more than the walk can still afford to tokenize; a file
|
|
# larger than the remainder fails closed exactly like an oversized one.
|
|
script_text, unsafe = _read_referenced_script(
|
|
script_path, max_bytes=budget.bytes_remaining
|
|
)
|
|
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.
|
|
if not budget.charge_remote_read():
|
|
return _budget_exhausted("remote reads", depth)
|
|
# 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)),
|
|
max_bytes=budget.bytes_remaining,
|
|
)
|
|
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,
|
|
budget=budget,
|
|
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(),
|
|
budget=_LifecycleScanBudget(),
|
|
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)
|
|
if resolved_script is not None:
|
|
try:
|
|
real_script = resolved_script.resolve(strict=False)
|
|
except (OSError, ValueError):
|
|
real_script = resolved_script
|
|
if _is_cloud_placeholder_path(resolved_script) or _is_cloud_placeholder_path(
|
|
real_script
|
|
):
|
|
# Attribute the refusal correctly: the script is not known to
|
|
# contain a lifecycle command — it lives on a cloud-synced
|
|
# FileProvider path (iCloud Drive / ~/Library/CloudStorage)
|
|
# that the guard refuses to open because an evicted
|
|
# placeholder can hang preflight indefinitely (#88052).
|
|
# Fail closed with the real reason instead of implying a
|
|
# dangerous lifecycle command.
|
|
raise GatewayLifecycleBlocked(
|
|
"Blocked: the cron script lives on a cloud-synced path "
|
|
"(iCloud Drive / ~/Library/CloudStorage). Opening an "
|
|
"evicted FileProvider placeholder can hang the guard's "
|
|
"preflight scan indefinitely, so it is refused without "
|
|
"being read. Move the script to a local, non-cloud path "
|
|
"(e.g. ~/.hermes/scripts/) and recreate the job."
|
|
)
|
|
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.
|
|
# The data-exemption masker tokenizes the text with shlex, so it is
|
|
# charged against the same walk budget as the shell path (#78398).
|
|
if not _LifecycleScanBudget().charge_text(combined):
|
|
unsafe = _budget_exhausted("text", 0)
|
|
else:
|
|
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."
|
|
)
|