"""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 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"(?:(? -- # ` (or `launchctl bootstrap gui/ `) 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 gateway restart|stop` / `--profile ` # / `--profile=`. 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=` or the space-separated # `-p ` / `--profile ` — 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 ... 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 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 .` 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"!", # 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 ``/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." )