205 lines
9.5 KiB
Python
205 lines
9.5 KiB
Python
"""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.
|
|
"""
|
|
|
|
import contextlib
|
|
import fnmatch
|
|
import logging
|
|
import re
|
|
import time
|
|
import uuid
|
|
from tools import approval_context as _ctx
|
|
from tools.approval_detection import (
|
|
_MALFORMED_EXEC_DESCRIPTION, _PARSER_LIMIT_DESCRIPTION, _deny_command_variants)
|
|
|
|
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."""
|
|
try:
|
|
deny_patterns = _ctx._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 _deny_command_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) -> str | None:
|
|
"""Persist a parser-limit-blocked command as a runnable script. That block
|
|
fires on payload SIZE/shape, not the operation — usually a legitimate script
|
|
the model inlined. Saving it makes recovery one turn (`bash <file>`) 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"):
|
|
with contextlib.suppress(OSError):
|
|
if old.stat().st_mtime < cutoff:
|
|
old.unlink()
|
|
path = script_dir / f"blocked-{int(time.time())}-{uuid.uuid4().hex[:8]}.sh"
|
|
path.write_text(
|
|
"#!/usr/bin/env bash\n"
|
|
"# Auto-saved by Hermes: this command exceeded the inline command\n"
|
|
"# parser limit and was blocked from direct execution. Review it,\n"
|
|
f"# then run it via: bash {path}\n" + command + ("" if command.endswith("\n") else "\n"),
|
|
# Force UTF-8 + lossy decode so non-UTF-8 child output can't crash the gateway thread on
|
|
# locale-mismatched Windows (#53137).
|
|
# Force UTF-8 + lossy decode so non-UTF-8 child output can't crash the gateway thread on
|
|
# locale-mismatched Windows (#53137).
|
|
# Force UTF-8 + lossy decode so non-UTF-8 child output can't crash the gateway thread on
|
|
# locale-mismatched Windows (#53137).
|
|
encoding="utf-8", errors="replace",
|
|
)
|
|
return str(path)
|
|
except Exception:
|
|
logger.debug("failed to save blocked payload", exc_info=True)
|
|
return None
|
|
|
|
|
|
_RECOVERY_PREFIX = (
|
|
" RECOVERY: this block fires on oversized/unparseable inline "
|
|
"command payloads (heredocs, giant one-liners), not on the operation itself. "
|
|
)
|
|
|
|
|
|
def _hardline_block_result(description: str, command: str = "") -> dict:
|
|
"""Build the standard block result for a hardline match."""
|
|
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 = _save_blocked_payload(command) if command else None
|
|
if saved:
|
|
message += _RECOVERY_PREFIX + (
|
|
f"Your command was saved to {saved} — review it, then run: terminal(command=\"bash {saved}\"). "
|
|
"Do not retry inline."
|
|
)
|
|
else:
|
|
message += _RECOVERY_PREFIX + (
|
|
"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.
|
|
# Port of can1357/oh-my-pi#7553.
|
|
_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 ch == "\\" and quote != "'":
|
|
nxt = command[i + 1] if i + 1 < n else ""
|
|
if nxt in _SHELL_CONTROL_CHARS:
|
|
has_reinterpretable = True
|
|
i += 2
|
|
continue
|
|
if quote is not None:
|
|
if ch == quote:
|
|
quote = None
|
|
elif quote == '"' and ch in ("`", "$"):
|
|
return True # expansion is active inside double quotes
|
|
elif ch in _SHELL_CONTROL_CHARS:
|
|
has_reinterpretable = True
|
|
elif ch in ("'", '"'):
|
|
quote = ch
|
|
elif 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
|
|
elif 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 _has_allowlist_shell_operator(command):
|
|
return False
|
|
with _a._lock:
|
|
patterns = tuple(_a._permanent_approved)
|
|
for pattern in patterns:
|
|
pattern = pattern.strip() if isinstance(pattern, str) else ""
|
|
if pattern and (command == pattern or (any(ch in pattern for ch in "*?[")
|
|
and fnmatch.fnmatchcase(command, pattern))):
|
|
return True
|
|
return False
|