"""Pre-gate floors for :mod:`tools.approval`: decisions that never reach a prompt. Unconditional blocks (hardline, ``sudo -S`` password piping, the user's own ``approvals.deny`` globs) and the permanent command allowlist match. All of them run BEFORE yolo / ``approvals.mode: off`` / cron approve-mode; the allowlist runs after. Session state stays in ``tools.approval`` and is read through it at call time so tests that rebind it keep working. """ import fnmatch import logging import re import time import uuid from typing import Optional from tools.approval_detection import _MALFORMED_EXEC_DESCRIPTION, _PARSER_LIMIT_DESCRIPTION logger = logging.getLogger("tools.approval") def _match_user_deny_rule(command: str) -> str | None: """Return the matching ``approvals.deny`` glob, or None. User-defined fnmatch globs that block unconditionally — like the hardline floor, a match fires BEFORE the yolo / mode=off bypass ("never let the agent run this, even under yolo"). Case-insensitive, run over the same normalized/deobfuscated variants the dangerous-pattern detector uses so quoting tricks (``r\\m``, ``git st""atus``) can't sidestep a rule. """ from tools import approval as _a try: deny_patterns = _a._get_approval_config().get("deny") or [] except Exception: return None globs = [p.strip() for p in deny_patterns if isinstance(p, str) and p.strip()] if not globs: return None for command_variant in _a._command_detection_variants(command): candidate = command_variant.lower().strip() for pattern in globs: if fnmatch.fnmatchcase(candidate, pattern.lower()): return pattern return None def _user_deny_block_result(pattern: str) -> dict: """Build the standard block result for an ``approvals.deny`` match.""" return { "approved": False, "user_deny": True, "message": ( f"BLOCKED: this command matches the user-defined deny rule " f"'{pattern}' (approvals.deny in config.yaml). It cannot be " "executed via the agent — not even with --yolo, /yolo, or " "approvals.mode=off. Do NOT retry or rephrase this command; " "the user has explicitly forbidden it." ), } def _save_blocked_payload(command: str) -> Optional[str]: """Persist a parser-limit-blocked command as a runnable script. The parser-limit block fires on payload SIZE/shape, not the operation — usually a legitimate script the model inlined. Saving it makes recovery one turn (`bash `) instead of two, and is strictly safer than the hint-only path: the file goes through the normal execution pipeline (including the referenced-script content guard) and nothing runs here. Returns the path, or None on any failure (hint falls back to write_file). """ try: from hermes_constants import get_hermes_home script_dir = get_hermes_home() / "cache" / "blocked-scripts" script_dir.mkdir(parents=True, exist_ok=True) # Opportunistic cleanup: blocked payloads older than 7 days. cutoff = time.time() - 7 * 86400 for old in script_dir.glob("blocked-*.sh"): try: if old.stat().st_mtime < cutoff: old.unlink() except OSError: pass path = script_dir / f"blocked-{int(time.time())}-{uuid.uuid4().hex[:8]}.sh" path.write_text( "#!/bin/bash\n" "# Auto-saved by Hermes: this command exceeded the inline command\n" "# parser limit and was blocked from direct execution. Review it,\n" "# then run it via: bash " + str(path) + "\n" + command + ("\n" if not command.endswith("\n") else ""), encoding="utf-8", errors="replace", ) return str(path) except Exception: logger.debug("failed to save blocked payload", exc_info=True) return None def _hardline_block_result(description: str, command: str = "") -> dict: """Build the standard block result for a hardline match.""" from tools import approval as _a message = ( f"BLOCKED (hardline): {description}. " "This command is on the unconditional blocklist and cannot " "be executed via the agent — not even with --yolo, /yolo, " "approvals.mode=off, or cron approve mode. If you genuinely " "need to run it, run it yourself in a terminal outside the " "agent." ) # The parser-limit block is almost always a giant inline payload, not a # forbidden operation, and is typically followed by blind rephrase # retries — point at the saved script (or the write_file recipe). if description in (_PARSER_LIMIT_DESCRIPTION, _MALFORMED_EXEC_DESCRIPTION): saved = _a._save_blocked_payload(command) if command else None if saved: message += ( " RECOVERY: this block fires on oversized/unparseable inline " "command payloads (heredocs, giant one-liners), not on the " f"operation itself. Your command was saved to {saved} — " f"review it, then run: terminal(command=\"bash {saved}\"). " "Do not retry inline." ) else: message += ( " RECOVERY: this block fires on oversized/unparseable inline " "command payloads (heredocs, giant one-liners), not on the " "operation itself. Write the script to a file with write_file, " "then run it: terminal(command=\"bash /path/script.sh\") or " "\"python3 /path/script.py\". Do not retry inline." ) return { "approved": False, "hardline": True, "message": message, } def _sudo_stdin_block_result(description: str) -> dict: """Build the standard block result for sudo stdin guard.""" return { "approved": False, "message": ( f"BLOCKED: {description}. " "Do not pipe passwords to 'sudo -S' — this is a brute-force " "attack vector. Set SUDO_PASSWORD in your .env file if the " "agent needs passwordless sudo, or run the sudo command " "manually in your own terminal." ), } # Shell control characters that make a command compound when they appear # OUTSIDE quotes. Inside quotes they are literal to the outer shell — but they # become executable again if an option like `-c`/`-e`/`--eval` (or a git # `-c alias.x=!...`) hands the quoted argument to another interpreter, so quoted # control chars only disqualify a command when such an option is present. _SHELL_CONTROL_CHARS = frozenset("\n\r;&|<>`$()") _REINTERPRETED_ARGUMENT_RE = re.compile( r"(?:^|[ \t])(?:-[^-\s]*[ce]|--(?:command|eval))(?:[= \t]|$)" ) def _has_allowlist_shell_operator(command: str) -> bool: """Return True when a command is too compound for the allowlist shortcut. Quote-aware: metacharacters inside quotes or behind a backslash are literal arguments (``cargo bench -- '^a(b|c)$'``), not shell syntax. Still disqualifying: ``$`` or backtick inside DOUBLE quotes (expansion stays active), and any quoted/escaped control character when the command also carries a ``-c``/``-e``/``--command``/``--eval``-style option that would hand the quoted text to another interpreter. """ command = command or "" quote = None # None | "'" | '"' has_reinterpretable = False i = 0 n = len(command) while i < n: ch = command[i] if quote == "'": if ch == "'": quote = None elif ch in _SHELL_CONTROL_CHARS: has_reinterpretable = True i += 1 continue if ch == "\\": nxt = command[i + 1] if i + 1 < n else "" if nxt in _SHELL_CONTROL_CHARS: has_reinterpretable = True i += 2 continue if quote == '"': if ch == '"': quote = None elif ch in ("`", "$"): return True # expansion is active inside double quotes elif ch in _SHELL_CONTROL_CHARS: has_reinterpretable = True i += 1 continue if ch in ("'", '"'): quote = ch i += 1 continue if ch == "$": # Unquoted $ is only compound when it opens a substitution # ("$HOME" stays simple, matching the historical `\$\(` behavior). if i + 1 < n and command[i + 1] == "(": return True i += 1 continue if ch in _SHELL_CONTROL_CHARS and ch not in "()": return True i += 1 # An unterminated quote means we can't reason about the command shape. if quote is not None: return True return has_reinterpretable and bool(_REINTERPRETED_ARGUMENT_RE.search(command)) def _command_matches_permanent_allowlist(command: str) -> bool: """True when command_allowlist holds this exact command text or a matching glob. Permanent approvals historically store dangerous-pattern keys such as ``recursive delete``; manual entries are command text, possibly with shell-style wildcards like ``podman *``. """ from tools import approval as _a command = (command or "").strip() if not command or _a._has_allowlist_shell_operator(command): return False with _a._lock: patterns = tuple(_a._permanent_approved) for pattern in patterns: if not isinstance(pattern, str): continue pattern = pattern.strip() if not pattern: continue if command == pattern: return True if any(ch in pattern for ch in "*?[") and fnmatch.fnmatchcase(command, pattern): return True return False