From acb886569f821c8fecb27a047ac4ad2f0d1d8284 Mon Sep 17 00:00:00 2001 From: Patrick Erichsen Date: Thu, 27 Aug 2026 21:24:40 -0700 Subject: [PATCH 1/3] 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 e31b22541..b42f20745 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 34fd1eabd..0b63228f3 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/3] 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 b42f20745..2274d9c04 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 64ed77eb4..03d939b8a 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"), [ From 99c5b840b8c5057f1deedb002709aecbaefbdb7f Mon Sep 17 00:00:00 2001 From: Narendran Raghavan <32655573+rng1995@users.noreply.github.com> Date: Mon, 19 Oct 2026 04:01:07 -0700 Subject: [PATCH 3/3] fix(p2): close abrupt empty comments and keep run-together directives Two review blockers on #452: - `` and `` are abrupt empty comments; the HTML tokenizer closes them immediately. A P2 match no longer starts there, so the visible text after them is not reported as hidden up to a later `-->`. - `` has no separator or case transition, so identifier boundaries dropped a hidden instruction that main flags. `ignore`, `instruction(s)` and `transmit` rarely occur inside ordinary words, so they match as substrings again. `system`, `POST`, `GET` and `send` keep identifier boundaries, which is what keeps SYSTEMATIC, POSTGRES, GETTING and target from matching. Direct and graph-level regressions cover both forms, including a real hidden comment after an abrupt empty comment. Signed-off-by: Narendran Raghavan --- .../static_patterns_prompt_injection.py | 16 ++++-- tests/nodes/analyzers/test_static_patterns.py | 50 +++++++++++++++++++ tests/nodes/test_security_remediation.py | 47 +++++++++++++++++ 3 files changed, 109 insertions(+), 4 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index 3bdb4f127..9108ae9a7 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -94,12 +94,20 @@ def _is_p9_skipped_path(file_path: str) -> bool: _P2_IDENTIFIER_END = ( r"(?:(?![^\W_])|(?<=(?-i:[a-z0-9]))(?=(?-i:[A-Z]))|(?<=(?-i:[A-Z]))(?=(?-i:[A-Z][a-z])))" ) +# Keywords that are rarely part of ordinary words also match inside run-together +# text such as PLEASEIGNOREPREVIOUSINSTRUCTIONS, which has no separator or case +# transition to split on. Keywords that commonly start ordinary words (SYSTEMATIC, +# POSTGRES, GETTING, target, sending) keep identifier boundaries. +_P2_COMMENT_KEYWORD = ( + rf"(?:{_P2_IDENTIFIER_START}(?:system|POST|GET|send){_P2_IDENTIFIER_END}" + r"|ignore|instructions?|transmit)" +) P2_PATTERNS = [ ( - rf"" and "" are abrupt empty comments: the HTML tokenizer + # closes them immediately, so the text after them is visible. Never + # start a hidden-comment match there. + rf" visible system docs ", + " visible system docs ", + ], + ) + def test_p2_html_comment_match_does_not_start_at_abrupt_empty_comment(self, content: str): + """```` and ```` close immediately, so the text after them is visible.""" + 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_after_abrupt_empty_comment_is_still_detected(self): + """Skipping an abrupt empty comment does not hide a real comment after it.""" + state = { + "components": ["SKILL.md"], + "file_cache": { + "SKILL.md": " visible text " + }, + } + + findings = static_runner.run_static_patterns(state, [prompt_injection_module]) + + assert 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 = { @@ -155,6 +186,25 @@ def test_p2_html_comment_keyword_rejects_all_caps_prefixes(self, content: str): assert not any(f.rule_id == "P2" for f in findings) + @pytest.mark.parametrize( + "content", + [ + "", + "", + "", + ], + ) + def test_p2_html_comment_detects_run_together_instructions(self, content: str): + """Low-collision keywords match inside run-together text without boundaries.""" + 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_html_comment_detects_snake_case_instructions(self): """Underscores remain valid separators around injection keywords.""" state = { diff --git a/tests/nodes/test_security_remediation.py b/tests/nodes/test_security_remediation.py index 05f395ec5..cd09f0008 100644 --- a/tests/nodes/test_security_remediation.py +++ b/tests/nodes/test_security_remediation.py @@ -2098,6 +2098,53 @@ def test_camel_case_hidden_instructions_block_safe_graph_verdict(tmp_path: Path) assert result["risk_recommendation"] != "SAFE" +def test_all_caps_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" + + +@pytest.mark.parametrize("opener", ["", ""]) +def test_abrupt_empty_comment_does_not_create_unsafe_graph_verdict( + tmp_path: Path, opener: str +) -> None: + (tmp_path / "SKILL.md").write_text( + f"""--- +name: visible-docs +description: Documents visible system behavior. +--- + +# Visible docs + +{opener} +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" + + def test_end_bang_comment_closer_does_not_create_unsafe_graph_verdict(tmp_path: Path) -> None: (tmp_path / "SKILL.md").write_text( """---