"""Pre-execution guards for the terminal tool. Pure functions that decide whether a command may run at all: workdir validation, the foreground long-lived/background-operator guidance, the supervised-gateway lifecycle block, and the Windows self-repo git guard. Each ``*_block`` helper returns a finished JSON error string, or None when the command may proceed. Split out of tools/terminal_tool.py; the origin module re-imports every public helper so ``tools.terminal_tool.`` keeps resolving. """ import json import logging import re import shlex import stat from pathlib import Path from typing import Any, Optional from tools.shell_heredoc import strip_inert_heredoc_bodies logger = logging.getLogger("tools.terminal_tool") # Workdir allowlist: Unicode alnum plus path/drive/UNC separators and common # punctuation; shell metacharacters stay rejected. Unicode is allowed on # purpose (e.g. CJK vault paths). Defense-in-depth — the cwd is also # shlex-quoted before reaching the shell. _WORKDIR_SAFE_ASCII_CHARS = frozenset('/\\:_-.~ +@=,') def _is_safe_workdir_char(ch: str) -> bool: if not ch or ord(ch) < 32 or ord(ch) == 127: # control chars / NUL return False return ch.isalnum() or ch in _WORKDIR_SAFE_ASCII_CHARS def _validate_workdir(workdir: str) -> str | None: """Error message if *workdir* has a disallowed character, else None. Allowlist rather than deny-list so novel metacharacters can't slip through.""" if not workdir: return None for ch in workdir: if not _is_safe_workdir_char(ch): return ( f"Blocked: workdir contains disallowed character {repr(ch)}. " "Use a simple filesystem path without shell metacharacters." ) return None def _safe_command_preview(command: Any, limit: int = 200) -> str: """Return a log-safe preview for possibly-invalid command values.""" if command is None: return "" if isinstance(command, str): return command[:limit] try: return repr(command)[:limit] except Exception: return f"<{type(command).__name__}>" _SHELL_LEVEL_BACKGROUND_RE = re.compile( r"(?:^|[;&|]\s*|&&\s*|\|\|\s*|\$\(\s*)(?:nohup|disown|setsid)\b", re.IGNORECASE | re.MULTILINE ) _INLINE_BACKGROUND_AMP_RE = re.compile(r"\s&\s") _TRAILING_BACKGROUND_AMP_RE = re.compile(r"\s&\s*(?:#.*)?$") def _strip_quotes(command: str) -> str: """Blank quoted / backtick content and provably-inert heredoc bodies so regex checks can't match keywords (nohup, setsid, '&') inside strings. Heredocs are masked FIRST: their delimiter may itself be quoted (``<<'EOF'``). ``strip_inert_heredoc_bodies`` is conservative — only a quoted, terminated delimiter on a simple opener fed to a known non-shell consumer is masked, so a real background operator can't hide in one. """ result = strip_inert_heredoc_bodies(command) result = re.sub(r"'[^']*'", "''", result) result = re.sub(r'"(?:[^"\\]|\\.)*"', '""', result) result = re.sub(r"`[^`]*`", "``", result) return result _LONG_LIVED_FOREGROUND_PATTERNS = ( re.compile(r"\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|watch)\b", re.IGNORECASE), re.compile(r"\bdocker\s+compose\s+up\b", re.IGNORECASE), re.compile(r"\bnext\s+dev\b", re.IGNORECASE), re.compile(r"\bvite(?:\s|$)", re.IGNORECASE), re.compile(r"\bnodemon\b", re.IGNORECASE), re.compile(r"\buvicorn\b", re.IGNORECASE), re.compile(r"\bgunicorn\b", re.IGNORECASE), re.compile(r"\bpython(?:3)?\s+-m\s+http\.server\b", re.IGNORECASE), ) def _looks_like_help_or_version_command(command: str) -> bool: """Return True for informational invocations that should never be blocked.""" normalized = " ".join(command.lower().split()) return ( " --help" in normalized or normalized.endswith(" -h") or " --version" in normalized or normalized.endswith(" -v") ) def _foreground_background_guidance(command: str) -> str | None: """Guidance text when a foreground command looks long-lived or uses shell backgrounding (it should be a managed background session), else None.""" if _looks_like_help_or_version_command(command): return None unquoted = _strip_quotes(command) if _SHELL_LEVEL_BACKGROUND_RE.search(unquoted): return ( "Foreground command uses shell-level background wrappers (nohup/disown/setsid). " "Re-send WITHOUT the wrapper as terminal(command=\"\", background=true, " "notify_on_complete=true) so Hermes tracks the process, then run readiness " "checks and tests in separate commands." ) if _INLINE_BACKGROUND_AMP_RE.search(unquoted) or _TRAILING_BACKGROUND_AMP_RE.search(unquoted): return ( "Foreground command uses '&' backgrounding. Re-send WITHOUT the '&' as " "terminal(command=\"\", background=true) — add notify_on_complete=true " "for bounded jobs — then run health checks and tests in follow-up terminal calls." ) for pattern in _LONG_LIVED_FOREGROUND_PATTERNS: if pattern.search(unquoted): return ( "This foreground command appears to start a long-lived server/watch process. " "Run it with background=true, verify readiness (health endpoint/log signal), " "then execute tests in a separate command." ) return None def gateway_lifecycle_block( *, command: str, env: Any, env_type: str, cwd: str, workdir: Optional[str], session_key: str, ) -> Optional[str]: """Refuse gateway lifecycle commands issued from inside the supervised gateway. ``systemctl``/``launchctl``/``hermes gateway restart|stop|uninstall`` targeting hermes-gateway would SIGTERM the gateway — and this very subprocess — before completing, so the service may never come back. Applies unconditionally (``force=True`` cannot bypass it). Gated on the SUPERVISED-gateway probe, not the raw ``_HERMES_GATEWAY`` marker: that marker leaks into every process that merely imports gateway.run (hermes serve, CLI, web server), which must still be able to restart the gateway; an unsupervised foreground ``hermes gateway run`` has no KeepAlive to turn a self-restart into a respawn loop, so it passes too. Returns the JSON error string when blocked, else None. """ from tools.process_registry import _is_supervised_gateway_process from tools.terminal_tool import _resolve_command_cwd, get_session_cwd if not _is_supervised_gateway_process(): return None from cron.lifecycle_guard import ( _MAX_REFERENCED_SCRIPT_BYTES, contains_gateway_lifecycle_command_or_referenced_script, contains_launchctl_submit_command, ) if contains_launchctl_submit_command(command): return json.dumps({ "output": "", "exit_code": 1, "error": ( "Blocked: launchctl submit/bootstrap registers a persistent " "KeepAlive job and is unsafe from inside the gateway process. " "Use Hermes cron for one-shot delayed work, or install an " "explicit LaunchAgent from a separate shell." ), "status": "error", }, ensure_ascii=False) guard_cwd_base = get_session_cwd(session_key) if guard_cwd_base is None: guard_cwd_base = getattr(env, "cwd", None) or cwd guard_cwd = _resolve_command_cwd( workdir=workdir, default_cwd=guard_cwd_base, session_key=session_key, env_type=env_type, ) def _read_script_in_env(script_path: str) -> Optional[str]: """Best-effort script read: host filesystem first, then a bounded ``env.execute('head -c ... < path')`` for remote backends.""" if env is None: return None try: local_path = Path(script_path).expanduser() if not local_path.is_absolute(): local_path = Path(guard_cwd) / local_path if local_path.is_file(): metadata = local_path.stat() if stat.S_ISREG(metadata.st_mode) and metadata.st_size <= _MAX_REFERENCED_SCRIPT_BYTES: data = local_path.read_bytes() if len(data) <= _MAX_REFERENCED_SCRIPT_BYTES: if b"\x00" in data: # Binary, not a script: feeding it to the guard # tokenizes machine code into bogus paths and # crashes the scanner. Nothing to scan. return None return data.decode("utf-8", errors="replace") except Exception: pass # Remote backend: bound the read at the source with `head -c` so an # oversized binary never crosses the wire (an unbounded `cat` once # pinned the gateway's tool thread for 30+ min on a shlex scan). One # byte over budget is enough for lifecycle_guard to fail closed. The # `< path` redirect keeps leading-dash paths out of argv. try: result = env.execute( f"head -c {_MAX_REFERENCED_SCRIPT_BYTES + 1} " f"< {shlex.quote(script_path)}" ) if result.get("returncode", -1) == 0: output = result.get("output", "") if output and "\x00" in output: # binary, see above return None return output except Exception: pass return None if contains_gateway_lifecycle_command_or_referenced_script( command, cwd=guard_cwd, read_remote_script=_read_script_in_env, ): return json.dumps({ "output": "", "exit_code": 1, "error": ( "Blocked: command or referenced script cannot restart, stop, or " "uninstall the gateway from inside the gateway process. The gateway would " "kill this command before it could complete (SIGTERM propagates " "to child processes). Run `hermes gateway restart` from a " "separate shell outside the running gateway." ), "status": "error", }, ensure_ascii=False) return None def self_repo_block( *, command: str, cwd: str, workdir: Optional[str], session_key: str, ) -> Optional[str]: """Windows-only guard against git-mutating the checkout backing this interpreter. NTFS locks loaded module files, so rewriting the live checkout can corrupt the running process; POSIX keeps old inodes alive for open handles, so the guard is off there (``guard_active``). Local backend only — remote backends cannot reach that checkout. Returns the JSON error string when blocked, else None. """ from tools.self_repo_guard import detect_self_repo_git_mutation, guard_active from tools.terminal_tool import _resolve_command_cwd if not guard_active(): return None guard_cwd = _resolve_command_cwd( workdir=workdir, default_cwd=cwd, session_key=session_key, ) hit, msg = detect_self_repo_git_mutation(command, guard_cwd) if not hit: return None logger.warning( "Blocked self-repo git mutation (command: %s)", _safe_command_preview(command), ) return json.dumps({ "output": "", "exit_code": 1, "error": msg, "status": "blocked", }, ensure_ascii=False)