297 lines
11 KiB
Python
297 lines
11 KiB
Python
"""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.<name>``
|
|
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 "<None>"
|
|
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=\"<cmd>\", 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=\"<cmd>\", 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)
|