Root cause
Injection & unsafe execution
Harden agent attack surface: scan writes to memory, skills, cron, and context files
How AI contributed: Flawed AI-written codePublished May 26, 2026 in
nousresearch/hermes-agentAll 2 findings in this repository
Indirect prompt injection (CWE-185/CWE-20/CWE-1427) scanner bypass in agent/prompt_builder.py::_scan_context_content: context files (AGENTS.md, CLAUDE.md, .cursorrules, .hermes.md, SOUL.md) are auto-loaded on session start from the victim's cwd into the LLM system prompt ('# Project Context' block that the model is instructed to follow). The blocklist's first pattern - ignore\s+(previous|all|above|prior)\s+instructions - only matches when a single keyword sits immediately before 'instructions'; multi-word variants ('ignore all prior instructions', 'ignore my previous instructions', 'ignore any and all instructions') walk through the regex, the scanner returns the content unchanged, and attacker instructions are concatenated verbatim into the outbound system prompt - zero-click full prompt override on the first turn, chainable into arbitrary tool use (terminal, write_file, patch, memory, delegate_task). Attack delivery: victim merely runs the agent inside a hostile repo (single click, no network callback).
Root cause
Harden agent attack surface: scan writes to memory, skills, cron, and context files
Fix
feat(security): promptware defense — shared threat patterns + memory load-time scan + tool-result delimiters (#32269)
Case facts
@@ -12,6 +12,50 @@ logger = logging.getLogger(__name__) +# ---------------------------------------------------------------------------+# Context file scanning — detect prompt injection in AGENTS.md, .cursorrules,+# SOUL.md before they get injected into the system prompt.+# ---------------------------------------------------------------------------++_CONTEXT_THREAT_PATTERNS = [+ (r'ignore\s+(previous|all|above|prior)\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'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),+ (r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),+ (r'<\s*div\s+style\s*=\s*["\'].*display\s*:\s*none', "hidden_div"),+ (r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),+ (r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),+ (r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),+]++_CONTEXT_INVISIBLE_CHARS = {+ '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',+ '\u202a', '\u202b', '\u202c', '\u202d', '\u202e',+}+++def _scan_context_content(content: str, filename: str) -> str:+ """Scan context file content for injection. Returns sanitized content."""+ findings = []++ # Check invisible unicode+ for char in _CONTEXT_INVISIBLE_CHARS:+ if char in content:+ findings.append(f"invisible unicode U+{ord(char):04X}")++ # Check threat patterns+ for pattern, pid in _CONTEXT_THREAT_PATTERNS:+ if re.search(pattern, content, re.IGNORECASE):+ findings.append(pid)++ if findings:+ logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))+ return f"[BLOCKED: {filename} contained potential prompt injection ({', '.join(findings)}). Content not loaded.]"++ return content+ # ========================================================================= # Constants # =========================================================================@@ -215,6 +259,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: content = agents_path.read_text(encoding="utf-8").strip() if content: rel_path = agents_path.relative_to(cwd_path)+ content = _scan_context_content(content, str(rel_path)) total_agents_content += f"## {rel_path}\n\n{content}\n\n" except Exception as e: logger.debug("Could not read %s: %s", agents_path, e)@@ -230,6 +275,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: try: content = cursorrules_file.read_text(encoding="utf-8").strip() if content:+ content = _scan_context_content(content, ".cursorrules") cursorrules_content += f"## .cursorrules\n\n{content}\n\n" except Exception as e: logger.debug("Could not read .cursorrules: %s", e)@@ -241,6 +287,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: try: content = mdc_file.read_text(encoding="utf-8").strip() if content:+ content = _scan_context_content(content, f".cursor/rules/{mdc_file.name}") cursorrules_content += f"## .cursor/rules/{mdc_file.name}\n\n{content}\n\n" except Exception as e: logger.debug("Could not read %s: %s", mdc_file, e)@@ -265,6 +312,7 @@ def build_context_files_prompt(cwd: Optional[str] = None) -> str: try: content = soul_path.read_text(encoding="utf-8").strip() if content:+ content = _scan_context_content(content, "SOUL.md") content = _truncate_content(content, "SOUL.md") sections.append( f"## SOUL.md\n\nIf SOUL.md is present, embody its persona and tone. "@@ -29,43 +29,30 @@ logger = logging.getLogger(__name__) # ----------------------------------------------------------------------------# Context file scanning — detect prompt injection in AGENTS.md, .cursorrules,-# SOUL.md before they get injected into the system prompt.+# Context file scanning — detect prompt injection / promptware in AGENTS.md,+# .cursorrules, SOUL.md before they get injected into the system prompt.+#+# Patterns live in ``tools/threat_patterns.py`` — the single source of truth+# shared with the memory-tool scanner and the tool-result delimiter system.+# This module just chooses how to react when a match is found (block-with-+# placeholder; the actual content never reaches the system prompt). # --------------------------------------------------------------------------- -_CONTEXT_THREAT_PATTERNS = [- (r'ignore\s+(previous|all|above|prior)\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'act\s+as\s+(if|though)\s+you\s+(have\s+no|don\'t\s+have)\s+(restrictions|limits|rules)', "bypass_restrictions"),- (r'<!--[^>]*(?:ignore|override|system|secret|hidden)[^>]*-->', "html_comment_injection"),- (r'<\s*div\s+style\s*=\s*["\'][\s\S]*?display\s*:\s*none', "hidden_div"),- (r'translate\s+.*\s+into\s+.*\s+and\s+(execute|run|eval)', "translate_execute"),- (r'curl\s+[^\n]*\$\{?\w*(KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL|API)', "exfil_curl"),- (r'cat\s+[^\n]*(\.env|credentials|\.netrc|\.pgpass)', "read_secrets"),-]--_CONTEXT_INVISIBLE_CHARS = {- '\u200b', '\u200c', '\u200d', '\u2060', '\ufeff',- '\u202a', '\u202b', '\u202c', '\u202d', '\u202e',-}+from tools.threat_patterns import scan_for_threats as _scan_for_threats def _scan_context_content(content: str, filename: str) -> str:- """Scan context file content for injection. Returns sanitized content."""- findings = []-- # Check invisible unicode- for char in _CONTEXT_INVISIBLE_CHARS:- if char in content:- findings.append(f"invisible unicode U+{ord(char):04X}")-- # Check threat patterns- for pattern, pid in _CONTEXT_THREAT_PATTERNS:- if re.search(pattern, content, re.IGNORECASE):- findings.append(pid)-+ """Scan context file content for injection. Returns sanitized content.++ Uses the "context" scope from the shared threat-pattern library, which+ covers classic injection + promptware/C2 patterns + role-play hijack.+ Strict-scope patterns (SSH backdoor, persistence, exfil-URL) are NOT+ applied here — those are too aggressive for a context file in a+ cloned repo (security research, infra docs). Content matching is+ BLOCKED at this layer because the file would otherwise enter the+ system prompt verbatim and the user has no chance to intervene.+ """+ findings = _scan_for_threats(content, scope="context") if findings: logger.warning("Context file %s blocked: %s", filename, ", ".join(findings)) return f"[BLOCKED: {filename} contained potential prompt injection ({', '.join(findings)}). Content not loaded.]"AI-assisted change 1f4af608cda02c00bbb567410a58943d0d55bd81e4b1330c7f2da9178800ca08 · Fix e6bc154d04c74f7aec1ef696f1a1468d9e6f18e7703df17e367a0605a512ec3f