Files
hermes-agent/tools/terminal_tool_guards.py

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)