Files
hermes-agent/tools/cronjob_prompt_scan.py
T

167 lines
7.2 KiB
Python

"""Cron prompt threat scanning (extracted from tools/cronjob_tools.py).
Two surfaces, two pattern sets: the small user-authored prompt gets the strict
set; the assembled prompt (with skill bodies) gets only prose-proof directives.
"""
import logging
import re
# Single source of truth shared with the install-time scanner (skills_guard):
# a narrower cron-local copy once let obfuscated directives (invisible math
# operators, directional isolates) slip past this runtime tripwire.
from tools.threat_patterns import INVISIBLE_CHARS as _CRON_INVISIBLE_CHARS
# Logger parity with the origin module (these functions used to log there).
logger = logging.getLogger("tools.cronjob_tools")
# Strict patterns — user prompt only. A directive-shaped cron prompt has no
# business containing `cat ~/.hermes/.env` or `rm -rf /`; there it is a
# smoking gun, not prose.
_CRON_THREAT_PATTERNS = [
(r'ignore\s+(?:\w+\s+)*(?:previous|all|above|prior)\s+(?:\w+\s+)*instructions', "prompt_injection"),
(r'do\s+not\s+tell\s+the\s+user', "deception_hide"),
(r'system\s+prompt\s+override', "sys_prompt_override"),
(r'disregard\s+(your|all|any)\s+(instructions|rules|guidelines)', "disregard_rules"),
(r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass|id_rsa|id_ed25519|id_ecdsa)', "read_secrets"),
(r'authorized_keys', "ssh_backdoor"),
(r'/etc/sudoers|visudo', "sudoers_mod"),
(r'rm\s+-rf\s+/', "destructive_root_rm"),
]
# Looser set — assembled prompt with skills attached. Command-shape patterns are
# dropped because skill markdown (security postmortems, runbooks) legitimately
# *describes* those commands; skill bodies are already vetted at install time,
# so this is only a tripwire for unambiguous injection directives.
_CRON_SKILL_ASSEMBLED_PATTERNS = _CRON_THREAT_PATTERNS[:4]
_CRON_SECRET_VAR_RE = r'\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)\w*\}?'
# Obvious leak paths only: secret in the destination URL, in a POST/form body,
# or in an Authorization header to an arbitrary host.
_CRON_EXFIL_COMMAND_PATTERNS = [
(rf'curl\s+[^\n]*https?://[^\s"\'`]*{_CRON_SECRET_VAR_RE}', "exfil_curl_url"),
(rf'wget\s+[^\n]*https?://[^\s"\'`]*{_CRON_SECRET_VAR_RE}', "exfil_wget_url"),
(rf'curl\s+[^\n]*(?:--data(?:-raw|-binary|-urlencode)?|-d|--form|-F)\s+[^\n]*{_CRON_SECRET_VAR_RE}', "exfil_curl_data"),
(rf'wget\s+[^\n]*--post-(?:data|file)=[^\n]*{_CRON_SECRET_VAR_RE}', "exfil_wget_post"),
(rf'curl\s+[^\n]*(?:-H|--header)\s+["\']Authorization:\s*(?:Bearer|token)\s+{_CRON_SECRET_VAR_RE}["\']', "exfil_curl_auth_header"),
]
_BLOCKED_PATTERN_MSG = (
"Blocked: prompt matches threat pattern '{pid}'. Cron prompts must not "
"contain injection or exfiltration payloads."
)
# U+200D (ZWJ) is a required part of many emoji sequences (👨‍👩‍👧, 🏳️‍🌈).
# Block it between plain text, allow it inside an emoji grapheme cluster.
_EMOJI_NEIGHBOUR_CP_RANGES = (
(0x1F000, 0x1FFFF),
(0x2600, 0x27BF),
(0x2300, 0x23FF),
(0x1F1E6, 0x1F1FF),
(0x20E3, 0x20E3),
)
_VARIATION_SELECTOR_CP = 0xFE0F
def _is_emoji_cp(cp: int) -> bool:
return any(lo <= cp <= hi for lo, hi in _EMOJI_NEIGHBOUR_CP_RANGES)
def _zwj_has_emoji_neighbour(text: str, idx: int) -> bool:
"""True when the ZWJ at text[idx] sits between emoji codepoints (skipping VS16)."""
left = idx - 1
while left >= 0 and ord(text[left]) == _VARIATION_SELECTOR_CP:
left -= 1
right = idx + 1
while right < len(text) and ord(text[right]) == _VARIATION_SELECTOR_CP:
right += 1
return (
left >= 0 and right < len(text)
and _is_emoji_cp(ord(text[left]))
and _is_emoji_cp(ord(text[right]))
)
def _strip_cron_safe_constructs(prompt: str) -> str:
"""Scrub the bundled GitHub skill's `Authorization: token $GITHUB_TOKEN` +
api.github.com curl so it doesn't trip the auth-header exfil rule.
re.sub scrubs EVERY occurrence (a job loading several GitHub skills has
many). The trailing ``[^\\s;&|$`]*`` consumes only the URL path — never
separators or subshell openers — so a payload smuggled onto the same line
still gets scanned. Host must be exactly api.github.com followed by ``/``,
whitespace, quote, or end: lookalike authorities (api.github.com.evil.com,
api.github.com@evil.com) fall through to the exfil detectors.
"""
return re.sub(
rf'curl\s+[^\n;&|$`]*(?:-H|--header)\s+["\']Authorization:\s*token\s+{_CRON_SECRET_VAR_RE}["\']'
r'\s+["\']?https://api\.github\.com(?::\d+)?(?:/|\s|$|["\'])[^\s;&|$`]*',
'curl https://api.github.com/user',
prompt,
flags=re.IGNORECASE,
)
def _check_invisible_unicode(prompt: str) -> str:
"""Error string if the prompt holds invisible-unicode markers (emoji ZWJ allowed).
Reports the first offender in ``_CRON_INVISIBLE_CHARS`` order."""
removed = set(_strip_invisible_unicode(prompt)[1])
for char in _CRON_INVISIBLE_CHARS:
if f"U+{ord(char):04X}" in removed:
return f"Blocked: prompt contains invisible unicode U+{ord(char):04X} (possible injection)."
return ""
def _strip_invisible_unicode(prompt: str) -> tuple[str, list[str]]:
"""Strip invisible-unicode chars, keeping ZWJ inside legitimate emoji.
Returns ``(cleaned, sorted U+XXXX labels removed)``. Used for the
skills-attached path, where a stray zero-width space in vetted skill
content should be sanitized rather than permanently kill the job.
"""
if not prompt:
return prompt, []
removed: set[str] = set()
cleaned: list[str] = []
for idx, ch in enumerate(prompt):
if ch in _CRON_INVISIBLE_CHARS and not (ch == '\u200d' and _zwj_has_emoji_neighbour(prompt, idx)):
removed.add(f"U+{ord(ch):04X}")
continue
cleaned.append(ch)
return ''.join(cleaned), sorted(removed)
def _first_pattern_error(text: str, *pattern_sets) -> str:
for patterns in pattern_sets:
for pattern, pid in patterns:
if re.search(pattern, text, re.IGNORECASE):
return _BLOCKED_PATTERN_MSG.format(pid=pid)
return ""
def _scan_cron_prompt(prompt: str) -> str:
"""Strict scan of the USER-SUPPLIED prompt (create/update + runtime
defense-in-depth). Returns an error string when blocked, else ""."""
prompt_to_scan = _strip_cron_safe_constructs(prompt)
return _check_invisible_unicode(prompt_to_scan) or _first_pattern_error(
prompt_to_scan, _CRON_THREAT_PATTERNS, _CRON_EXFIL_COMMAND_PATTERNS
)
def _scan_cron_skill_assembled(assembled: str) -> tuple[str, str]:
"""Loose scan of the ASSEMBLED prompt (skill content included).
Invisible unicode is SANITIZED (stripped + logged), not blocked — the hard
block stays on raw user prompts, the actual injection surface. Returns
``(cleaned_prompt, error)`` with ``error`` empty when it passed.
"""
cleaned, removed = _strip_invisible_unicode(assembled)
if removed:
logger.warning(
"Cron skill-assembled prompt: stripped %d invisible-unicode "
"char(s) (%s) from vetted skill content",
len(removed), ", ".join(removed),
)
error = _first_pattern_error(_strip_cron_safe_constructs(cleaned), _CRON_SKILL_ASSEMBLED_PATTERNS)
return cleaned, error