mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
_scan_cron_prompt ran at cron create/update time on the user-supplied prompt but skill content loaded inside _build_job_prompt at runtime was never scanned. Combined with non-interactive auto-approval, a malicious skill carrying an injection payload could execute with full tool access every tick. - cron/scheduler.py: new CronPromptInjectionBlocked exception and _scan_assembled_cron_prompt helper. _build_job_prompt now routes both return paths (with skills / without skills) through the helper, raising on match. run_job catches the exception and returns a clean (False, blocked_doc, "", error) tuple so the operator sees a BLOCKED delivery with the scanner result and an audit hint, rather than a scheduler crash or a silent skip. - tests/cron/test_cron_prompt_injection_skill.py: 10 regression tests. Unit coverage on _scan_assembled_cron_prompt (clean/injection/exfil/ invisible-unicode). End-to-end coverage via _build_job_prompt with planted skills (injection payload, env exfil, zero-width space, clean control, missing-skill-doesn't-crash). Fixture patches tools.skills_tool.SKILLS_DIR / HERMES_HOME so planted skills are visible. Importantly uses the current cron.scheduler module object (not a top-level import) so tests don't break when other fixtures reload cron.scheduler — CronPromptInjectionBlocked identity depends on which module object defined it.
This commit is contained in:
parent
bbff2f6345
commit
a1fe5f473d
2 changed files with 282 additions and 3 deletions
|
|
@ -41,6 +41,19 @@ from hermes_time import now as _hermes_now
|
|||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CronPromptInjectionBlocked(Exception):
|
||||
"""Raised by _build_job_prompt when the fully-assembled prompt trips the
|
||||
injection scanner. Caught in run_job so the operator sees a clean
|
||||
"job blocked" delivery instead of the scheduler crashing.
|
||||
|
||||
Assembled-prompt scanning (including loaded skill content) plugs the
|
||||
gap from #3968: create-time scanning only covers the user-supplied
|
||||
prompt field; skill content loaded at runtime was never scanned, so a
|
||||
malicious skill could carry an injection payload that reached the
|
||||
non-interactive (auto-approve) cron agent.
|
||||
"""
|
||||
|
||||
|
||||
def _resolve_cron_enabled_toolsets(job: dict, cfg: dict) -> list[str] | None:
|
||||
"""Resolve the toolset list for a cron job.
|
||||
|
||||
|
|
@ -868,7 +881,7 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
|||
|
||||
skill_names = [str(name).strip() for name in skills if str(name).strip()]
|
||||
if not skill_names:
|
||||
return prompt
|
||||
return _scan_assembled_cron_prompt(prompt, job)
|
||||
|
||||
from tools.skills_tool import skill_view
|
||||
from tools.skill_usage import bump_use
|
||||
|
|
@ -911,7 +924,32 @@ def _build_job_prompt(job: dict, prerun_script: Optional[tuple] = None) -> str:
|
|||
|
||||
if prompt:
|
||||
parts.extend(["", f"The user has provided the following instruction alongside the skill invocation: {prompt}"])
|
||||
return "\n".join(parts)
|
||||
return _scan_assembled_cron_prompt("\n".join(parts), job)
|
||||
|
||||
|
||||
def _scan_assembled_cron_prompt(assembled: str, job: dict) -> str:
|
||||
"""Scan the fully-assembled cron prompt (including skill content) for
|
||||
injection patterns. Raises ``CronPromptInjectionBlocked`` when a match
|
||||
fires so ``run_job`` can surface a clear refusal to the operator.
|
||||
|
||||
Plugs the #3968 gap: ``_scan_cron_prompt`` runs on the user-supplied
|
||||
prompt at create/update, but skill content is loaded from disk at
|
||||
runtime and was never scanned. Since cron runs non-interactively
|
||||
(auto-approves tool calls), a malicious skill carrying an injection
|
||||
payload bypassed every gate.
|
||||
"""
|
||||
from tools.cronjob_tools import _scan_cron_prompt
|
||||
|
||||
scan_error = _scan_cron_prompt(assembled)
|
||||
if scan_error:
|
||||
job_label = job.get("name") or job.get("id") or "<unknown>"
|
||||
logger.warning(
|
||||
"Cron job '%s': assembled prompt blocked by injection scanner — %s",
|
||||
job_label,
|
||||
scan_error,
|
||||
)
|
||||
raise CronPromptInjectionBlocked(scan_error)
|
||||
return assembled
|
||||
|
||||
|
||||
def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
||||
|
|
@ -1066,7 +1104,31 @@ def run_job(job: dict) -> tuple[bool, str, str, Optional[str]]:
|
|||
)
|
||||
return True, silent_doc, SILENT_MARKER, None
|
||||
|
||||
prompt = _build_job_prompt(job, prerun_script=prerun_script)
|
||||
try:
|
||||
prompt = _build_job_prompt(job, prerun_script=prerun_script)
|
||||
except CronPromptInjectionBlocked as block_exc:
|
||||
# Assembled prompt (user prompt + loaded skill content) tripped the
|
||||
# injection scanner. Refuse to run the agent this tick and surface
|
||||
# a clear failure to the operator so they see WHY the scheduled job
|
||||
# didn't run and can audit the offending skill.
|
||||
logger.warning(
|
||||
"Job '%s' (ID: %s): blocked by prompt-injection scanner — %s",
|
||||
job_name, job_id, block_exc,
|
||||
)
|
||||
blocked_doc = (
|
||||
f"# Cron Job: {job_name}\n\n"
|
||||
f"**Job ID:** {job_id}\n"
|
||||
f"**Run Time:** {_hermes_now().strftime('%Y-%m-%d %H:%M:%S')}\n"
|
||||
f"**Status:** BLOCKED\n\n"
|
||||
"The assembled prompt (user prompt + loaded skill content) tripped "
|
||||
"the cron injection scanner and the agent was NOT run.\n\n"
|
||||
f"**Scanner result:** {block_exc}\n\n"
|
||||
"Audit the skill(s) attached to this job for prompt-injection "
|
||||
"payloads or invisible-unicode markers. If the skill is legitimate "
|
||||
"and the match is a false positive, rephrase the content to avoid "
|
||||
"the threat pattern (`tools/cronjob_tools.py::_CRON_THREAT_PATTERNS`)."
|
||||
)
|
||||
return False, blocked_doc, "", str(block_exc)
|
||||
if prompt is None:
|
||||
logger.info("Job '%s': script produced no output, skipping AI call.", job_name)
|
||||
return True, "", SILENT_MARKER, None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue