From acb886569f821c8fecb27a047ac4ad2f0d1d8284 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 27 Aug 2026 21:24:40 -0700 Subject: [PATCH 1/2] fix(prompt-injection): constrain hidden comment matches Signed-off-by: Patrick Erichsen --- .../static_patterns_prompt_injection.py | 7 ++- tests/nodes/analyzers/test_static_patterns.py | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index e31b2254..b42f2074 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -75,7 +75,12 @@ def _is_p9_skipped_path(file_path: str) -> bool: # constant so hidden-instruction and padding detection cannot drift apart. _ZERO_WIDTH_PATTERN = "[" + "".join(sorted(ZERO_WIDTH_CHARS)) + "]" P2_PATTERNS = [ - (r"", 0.7), + ( + r").)*?(?).)*?-->", + 0.7, + ), (r"\[//\]:\s*#\s*\(.*?(?:system|instructions?|ignore|POST|GET|send|transmit).*?\)", 0.8), (_ZERO_WIDTH_PATTERN, 0.6), (r"[\u202a-\u202e\u2066-\u2069]", 0.85), diff --git a/tests/nodes/analyzers/test_static_patterns.py b/tests/nodes/analyzers/test_static_patterns.py index 34fd1eab..0b63228f 100644 --- a/tests/nodes/analyzers/test_static_patterns.py +++ b/tests/nodes/analyzers/test_static_patterns.py @@ -84,6 +84,49 @@ def test_p2_hidden_comment_produces_finding(self): assert len(findings) >= 1 assert any(f.rule_id == "P2" for f in findings) + def test_p2_html_comment_match_does_not_span_separate_comments(self): + """Visible instructions between marker comments are not hidden.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": ( + "\n" + "Follow these visible instructions.\n" + "" + ), + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + + def test_p2_html_comment_keyword_requires_whole_word(self): + """Benign words containing a keyword substring do not trigger P2.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": "", + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + + def test_p2_html_comment_detects_snake_case_instructions(self): + """Underscores remain valid separators around injection keywords.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": "", + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert any(f.rule_id == "P2" for f in findings) + def test_p2_bidi_control_chars_produce_finding(self): """Bidi control characters (Trojan Source CVE-2021-42574) yield P2.""" rlo = chr(0x202E) From 248a5b07e2679b65edd0c53d4acdb803e710d0bb Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Fri, 28 Aug 2026 10:45:10 -0700 Subject: [PATCH 2/2] fix: handle prompt injection comment boundaries Signed-off-by: Patrick Erichsen --- .../static_patterns_prompt_injection.py | 15 ++++- tests/nodes/analyzers/test_static_patterns.py | 58 +++++++++++++++++++ tests/nodes/test_security_remediation.py | 44 ++++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index b42f2074..2274d9c0 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -74,11 +74,20 @@ def _is_p9_skipped_path(file_path: str) -> bool: # P2: Hidden Instructions. Build the character class from the shared P9 # constant so hidden-instruction and padding detection cannot drift apart. _ZERO_WIDTH_PATTERN = "[" + "".join(sorted(ZERO_WIDTH_CHARS)) + "]" +# Treat separators, lower-to-upper transitions, and acronym-to-PascalCase transitions as +# identifier boundaries without matching keyword prefixes in ordinary alphanumeric words. +_P2_IDENTIFIER_START = ( + r"(?:(?).)*?(?).)*?-->", + rf" visible system docs ", + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + def test_p2_html_comment_keyword_requires_whole_word(self): """Benign words containing a keyword substring do not trigger P2.""" state = { @@ -114,6 +127,25 @@ def test_p2_html_comment_keyword_requires_whole_word(self): assert not any(f.rule_id == "P2" for f in findings) + @pytest.mark.parametrize( + "content", + [ + "", + "", + "", + ], + ) + def test_p2_html_comment_keyword_rejects_all_caps_prefixes(self, content: str): + """An uppercase suffix without a case transition is not a keyword boundary.""" + state = { + "components": ["SKILL.md"], + "file_cache": {"SKILL.md": content}, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert not any(f.rule_id == "P2" for f in findings) + def test_p2_html_comment_detects_snake_case_instructions(self): """Underscores remain valid separators around injection keywords.""" state = { @@ -127,6 +159,32 @@ def test_p2_html_comment_detects_snake_case_instructions(self): assert any(f.rule_id == "P2" for f in findings) + @pytest.mark.parametrize( + "content", + [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + ], + ) + def test_p2_html_comment_detects_camel_case_instructions(self, content: str): + """Case transitions remain valid separators around injection keywords.""" + state = { + "components": ["SKILL.md"], + "file_cache": {"SKILL.md": content}, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert any(f.rule_id == "P2" for f in findings) + def test_p2_bidi_control_chars_produce_finding(self): """Bidi control characters (Trojan Source CVE-2021-42574) yield P2.""" rlo = chr(0x202E) diff --git a/tests/nodes/test_security_remediation.py b/tests/nodes/test_security_remediation.py index 64ed77eb..03d939b8 100644 --- a/tests/nodes/test_security_remediation.py +++ b/tests/nodes/test_security_remediation.py @@ -1085,6 +1085,50 @@ def test_unicode_bypass_forms_retain_prompt_injection_rule(tmp_path: Path, conte assert all(finding.confidence == 0.8 for finding in p1) +def test_camel_case_hidden_instructions_block_safe_graph_verdict(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text( + """--- +name: prompt-boundary +description: Tests prompt boundary handling. +--- + +# Prompt Boundary + + +""", + encoding="utf-8", + ) + + result = graph.invoke({"input_path": str(tmp_path), "output_format": "json", "use_llm": False}) + + assert result["analysis_completeness"]["is_complete"] is True + assert any(finding.rule_id == "P2" for finding in result["filtered_findings"]) + assert result["risk_recommendation"] != "SAFE" + + +def test_end_bang_comment_closer_does_not_create_unsafe_graph_verdict(tmp_path: Path) -> None: + (tmp_path / "SKILL.md").write_text( + """--- +name: visible-docs +description: Documents visible system behavior. +--- + +# Visible docs + + +Visible system docs for operators. + +""", + encoding="utf-8", + ) + + result = graph.invoke({"input_path": str(tmp_path), "output_format": "json", "use_llm": False}) + + assert result["analysis_completeness"]["is_complete"] is True + assert not any(finding.rule_id == "P2" for finding in result["filtered_findings"]) + assert result["risk_recommendation"] == "SAFE" + + @pytest.mark.parametrize( ("ascii_content", "confusable_content", "rule_id"), [