fix(cron): split scanner into two tiers so skill prose stops false-positiving (#32339)

The runtime cron prompt scanner (added in #3968 to plug the
"malicious skill carrying an injection payload" gap) reuses the same
critical-severity patterns as the create-time user-prompt scan against
the *assembled* prompt — which includes loaded skill markdown.

That works fine for narrow patterns like "ignore previous instructions"
which never legitimately appear in prose. It catastrophically false-
positives on command-shape patterns like `cat ~/.hermes/.env`,
`authorized_keys`, `/etc/sudoers`, and `rm -rf /`, which routinely
appear in security postmortems and runbooks as **descriptive prose**
about attacks, not as actual commands.

Concrete failure: the bundled `hermes-agent-dev` skill contains a
security postmortem section saying "the attacker could just
`cat ~/.hermes/.env`". Every PR-scout cron job that loaded this skill
was silently blocked with `Blocked: prompt matches threat pattern
'read_secrets'`. All 11 scout jobs failed for weeks.

Fix: split the scanner into two tiers and route by context:

  - `_scan_cron_prompt` (strict, unchanged behavior) runs against
    the small user-authored cron prompt at create/update and as a
    runtime defense-in-depth when no skills are attached. A legit
    user prompt has no business saying `cat .env`, so the strict
    patterns still apply there.

  - `_scan_cron_skill_assembled` (new, looser) runs against the
    assembled prompt when skills are attached. It only catches
    unambiguous prompt-injection directives ("ignore previous
    instructions", "disregard your rules", "system prompt override",
    "do not tell the user") plus invisible-unicode markers. Command-
    shape patterns are dropped because they false-positive on prose.

This is defense-in-depth, not the only line of defense. Skill bodies
are already scanned at install time by `skills_guard.py`; the runtime
cron scan exists purely as a tripwire for an obvious injection
directive surviving a malicious install. Catching prose mentions of
commands was never the goal of #3968 — the test that planted a skill
containing `cat ~/.hermes/.env` was the wrong shape of test for the
threat model.

Tests:
- `_scan_cron_prompt` strict behavior preserved (56 existing tests
  unchanged: bare `cat .env`, `rm -rf /`, etc. still block).
- New `TestScanCronSkillAssembled` class verifies the looser scanner:
  injection / disregard / system-override / do-not-tell-the-user /
  invisible-unicode still block; descriptive prose about attack
  commands is allowed; GitHub auth-header allowlist still works.
- `test_skill_with_env_exfil_payload_raises` (planted `cat .env`
  in skill body) replaced with `test_skill_with_env_exfil_command
  _in_prose_is_allowed` documenting the new correct behavior with
  the real-world postmortem-style example that triggered the bug.
- All 11 originally-failing PR-scout jobs validated end-to-end via
  `_build_job_prompt` — assembled prompts now build successfully
  with the `hermes-agent-dev` skill attached.

Total: 75/75 tests in cron + cronjob_tools + threat scanner pass;
544/544 across the wider cron / memory / threat-pattern surface.
This commit is contained in:
Teknium 2026-05-25 18:20:45 -07:00 committed by GitHub
parent e3236e99a4
commit ccd899318e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 196 additions and 26 deletions

View file

@ -36,10 +36,36 @@ from cron.jobs import (
# ---------------------------------------------------------------------------
# Cron prompt scanning — critical-severity patterns only, since cron prompts
# run in fresh sessions with full tool access.
# Cron prompt scanning
# ---------------------------------------------------------------------------
#
# Two threat surfaces, two scanners:
#
# 1. User-supplied cron prompt (small, written as a directive).
# Strict scanning is appropriate — a legit cron prompt has no business
# saying "cat ~/.hermes/.env" or "rm -rf /". `_scan_cron_prompt()` runs
# against this at create/update time and as a runtime defense-in-depth.
#
# 2. Assembled prompt that includes loaded skill content (large markdown
# bodies, often security docs, postmortems, runbooks discussing attack
# patterns in PROSE). Reusing the strict patterns here false-positives
# every time a skill *describes* a command — see #3968 follow-up: the
# `hermes-agent-dev` skill contains a security postmortem mentioning
# `cat ~/.hermes/.env`, which tripped `read_secrets` and silently
# killed all PR-scout jobs.
#
# Skill bodies are user-curated and scanned at install time by
# `skills_guard.py`. The runtime cron scan only needs to catch the
# patterns whose phrasing does NOT survive normal English prose:
# classic prompt-injection directives ("ignore previous instructions",
# "disregard your rules"), deception directives, and invisible
# unicode. `_scan_cron_skill_assembled()` runs against the assembled
# prompt with this tighter pattern set.
#
# Both scanners share the invisible-unicode check and the GitHub Authorization
# header exemption.
# Strict patterns — applied to the user prompt only.
_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"),
@ -51,6 +77,20 @@ _CRON_THREAT_PATTERNS = [
(r'rm\s+-rf\s+/', "destructive_root_rm"),
]
# Looser pattern set — applied to the assembled prompt when skills are
# attached. Only patterns whose phrasing is unambiguous in any context;
# command-shape patterns are dropped because they false-positive on prose
# in security docs / postmortems. Skill bodies are scanned at install time
# by `skills_guard.py`, so the runtime cron scan is purely a tripwire for
# obvious injection directives surviving a malicious skill that slipped
# through install.
_CRON_SKILL_ASSEMBLED_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"),
]
_CRON_SECRET_VAR_RE = r'\$\{?\w*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)\w*\}?'
_CRON_EXFIL_COMMAND_PATTERNS = [
# Tighten exfil detection to obvious leak paths: embedding a secret
@ -114,23 +154,48 @@ def _strip_legitimate_emoji_zwj(prompt: str) -> str:
return ''.join(cleaned)
def _scan_cron_prompt(prompt: str) -> str:
"""Scan a cron prompt for critical threats. Returns error string if blocked, else empty."""
def _strip_cron_safe_constructs(prompt: str) -> str:
"""Strip the GitHub `Authorization: token $GITHUB_TOKEN` auth-header
pattern so it doesn't trip the broader curl-auth-header exfil rule.
Allows the bundled GitHub skill fallback without opening a blanket
exemption for arbitrary Authorization-header exfiltration.
"""
github_auth_header = re.search(
rf'curl\s+[^\n]*(?:-H|--header)\s+["\']Authorization:\s*token\s+{_CRON_SECRET_VAR_RE}["\']'
r'\s+["\']?https://api\.github\.com(?:/|\b)',
prompt,
re.IGNORECASE,
)
prompt_to_scan = prompt
if github_auth_header:
# Allow the bundled GitHub skill fallback shape without opening a
# blanket exemption for arbitrary Authorization-header exfiltration.
prompt_to_scan = prompt.replace(github_auth_header.group(0), "curl https://api.github.com/user")
prompt_for_invisible_scan = _strip_legitimate_emoji_zwj(prompt_to_scan)
return prompt.replace(github_auth_header.group(0), "curl https://api.github.com/user")
return prompt
def _check_invisible_unicode(prompt: str) -> str:
"""Return an error string if the prompt contains invisible-unicode
injection markers (ZWJ inside legitimate emoji sequences is allowed).
"""
prompt_for_invisible_scan = _strip_legitimate_emoji_zwj(prompt)
for char in _CRON_INVISIBLE_CHARS:
if char in prompt_for_invisible_scan:
return f"Blocked: prompt contains invisible unicode U+{ord(char):04X} (possible injection)."
return ""
def _scan_cron_prompt(prompt: str) -> str:
"""Scan the USER-SUPPLIED cron prompt for critical threats.
Strict pattern set used at job create/update time and as a runtime
defense-in-depth for prompts authored before the scanner existed.
The user prompt is small and directive; bare `cat .env` or `rm -rf /`
there is a smoking gun, not prose. Returns an error string when
blocked, else empty string.
"""
prompt_to_scan = _strip_cron_safe_constructs(prompt)
invisible_err = _check_invisible_unicode(prompt_to_scan)
if invisible_err:
return invisible_err
for pattern, pid in _CRON_THREAT_PATTERNS:
if re.search(pattern, prompt_to_scan, re.IGNORECASE):
return f"Blocked: prompt matches threat pattern '{pid}'. Cron prompts must not contain injection or exfiltration payloads."
@ -140,6 +205,29 @@ def _scan_cron_prompt(prompt: str) -> str:
return ""
def _scan_cron_skill_assembled(assembled: str) -> str:
"""Scan an ASSEMBLED cron prompt that includes loaded skill content.
Looser pattern set only catches unambiguous prompt-injection
directives and invisible unicode. Drops command-shape patterns
(cat .env, rm -rf /, authorized_keys, /etc/sudoers) because they
false-positive on legitimate skill markdown that *describes* attack
commands in security postmortems and runbooks.
Skill bodies are user-curated and already scanned at install time
by `skills_guard.py`. This scan is the runtime tripwire for an
obvious injection directive surviving a malicious install.
"""
prompt_to_scan = _strip_cron_safe_constructs(assembled)
invisible_err = _check_invisible_unicode(prompt_to_scan)
if invisible_err:
return invisible_err
for pattern, pid in _CRON_SKILL_ASSEMBLED_PATTERNS:
if re.search(pattern, prompt_to_scan, re.IGNORECASE):
return f"Blocked: prompt matches threat pattern '{pid}'. Cron prompts must not contain injection or exfiltration payloads."
return ""
def _origin_from_env() -> Optional[Dict[str, str]]:
from gateway.session_context import get_session_env
origin_platform = get_session_env("HERMES_SESSION_PLATFORM")