From 1f868573e9998810db3335884966f3da5a70d916 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Wed, 14 Oct 2026 08:58:54 -0700 Subject: [PATCH 1/4] fix(p2): bound Markdown reference-comment findings Signed-off-by: Christopher Kevin --- .../static_patterns_prompt_injection.py | 85 ++++++++++++++++++- 1 file changed, 84 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 4bf1ea076..32ed83c5d 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -86,9 +86,31 @@ def _is_p9_skipped_path(file_path: str) -> bool: # paired controls can, which is the same line GCC's -Wbidi-chars draws by # default. Do not add them here without revisiting that tradeoff. _BIDI_CONTROL_PATTERN = r"[\u202a-\u202e\u2066-\u2069]" +# Reference-comment titles need a source delimiter scan, rather than a DOTALL +# keyword expression that can backtrack past an already closed title. +_REFERENCE_COMMENT_GAP = r"[ \t]*(?:(?:\r\n|\r|\n)[ \t]*)?" +_REFERENCE_COMMENT_PATTERN = rf"\[//\]:{_REFERENCE_COMMENT_GAP}#{_REFERENCE_COMMENT_GAP}\(" +_REFERENCE_COMMENT_OPENING = re.compile(_REFERENCE_COMMENT_PATTERN) +_REFERENCE_COMMENT_SPAN = re.compile(r"[\s\S]*") +# Preserve separator/snake_case, camelCase and acronym-to-PascalCase signals +# without treating ordinary words such as "target" or "POSTGRES" as commands. +_REFERENCE_KEYWORD_BOUNDARY = ( + r"(?:(?", 0.7), - (r"\[//\]:\s*#\s*\(.*?(?:system|instructions?|ignore|POST|GET|send|transmit).*?\)", 0.8), + (_REFERENCE_COMMENT_PATTERN, 0.8), (_ZERO_WIDTH_PATTERN, 0.6), (r"data:text/plain;base64,[A-Za-z0-9+/=]{50,}", 0.7), ] @@ -259,6 +281,9 @@ def _p2_pattern_matches( """Yield all structured matches or the first control signal on each line.""" if check_runtime is not None: check_runtime() + if pattern == _REFERENCE_COMMENT_PATTERN: + yield from _reference_comment_matches(content, check_runtime) + return compiled = re.compile(pattern, re.IGNORECASE | re.DOTALL) if pattern not in _SINGLE_CHARACTER_P2_PATTERNS: for match in compiled.finditer(content): @@ -288,6 +313,64 @@ def _p2_pattern_matches( cursor = line_break.end() +def _reference_comment_matches( + content: str, + check_runtime: Callable[[], None] | None = None, +) -> Iterator[re.Match[str]]: + """Own a title up to its first unescaped source closer or a blank line. + + CommonMark parenthesized titles allow escaped parentheses and line breaks, + but no blank lines. Do not exempt malformed nested or blank-line titles + wholesale: an existing keyword in the owned prefix remains conservative + evidence. Later visible paragraphs cannot supply that keyword. + """ + cursor = 0 + while cursor < len(content): + if check_runtime is not None: + check_runtime() + opening = _REFERENCE_COMMENT_OPENING.search(content, cursor) + if opening is None: + return + title_start = opening.end() + end = title_start + closed = False + blank_line = False + line_has_text = True + next_runtime_check = title_start + while end < len(content): + if check_runtime is not None and end >= next_runtime_check: + check_runtime() + next_runtime_check = end + 1024 + char = content[end] + if char == "\\" and end + 1 < len(content) and content[end + 1] in "\\()": + end += 2 + line_has_text = True + continue + if char == ")": + end += 1 + closed = True + break + if char in "\r\n": + if not line_has_text: + blank_line = True + break + line_has_text = False + if char == "\r" and end + 1 < len(content) and content[end + 1] == "\n": + end += 1 + elif char not in " \t": + line_has_text = True + end += 1 + if check_runtime is not None: + check_runtime() + if (closed or blank_line) and _REFERENCE_COMMENT_KEYWORD.search( + content, title_start, end - 1 if closed else end + ): + match = _REFERENCE_COMMENT_SPAN.match(content, opening.start(), end) + assert match is not None + yield match + cursor = max(end, title_start) + + def _first_smuggled_tag_offset( content: str, check_runtime: Callable[[], None] | None = None, From 9cdd1f8af72a7943b55dd9f1136d47269094a682 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Wed, 14 Oct 2026 08:58:54 -0700 Subject: [PATCH 2/4] test(p2): cover Markdown reference-comment boundaries Signed-off-by: Christopher Kevin --- .../analyzers/test_p2_reference_comments.py | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 tests/nodes/analyzers/test_p2_reference_comments.py diff --git a/tests/nodes/analyzers/test_p2_reference_comments.py b/tests/nodes/analyzers/test_p2_reference_comments.py new file mode 100644 index 000000000..130c422cc --- /dev/null +++ b/tests/nodes/analyzers/test_p2_reference_comments.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Reference-comment ownership regressions, using independent synthetic text.""" + +from __future__ import annotations + +import pytest + +from skillspector.models import compute_match_fingerprint +from skillspector.nodes.analyzers import static_patterns_prompt_injection as prompt_injection +from skillspector.nodes.analyzers import static_runner + + +def _p2(content: str): + return [ + finding + for finding in prompt_injection.analyze(content, "SKILL.md", "markdown") + if finding.rule_id == "P2" + ] + + +@pytest.mark.parametrize( + "visible", + [ + "Then target the endpoint (see below).", + "GET the page (documented here).", + "Follow the visible instructions (documented here).", + "Use the operating system (documented here).", + "Send feedback (documented here).", + "Transmit the visible message (documented here).", + "Ignore this visible example (documented here).", + ], +) +def test_closed_comment_cannot_borrow_visible_keyword(visible: str) -> None: + assert not _p2(f"[//]: # (layout note)\n\n{visible}") + + +@pytest.mark.parametrize( + "content", + [ + "[//]: #\n\n(GET a visible page).", + "[//]: #\r\n \t\r\n(GET a visible page).", + "[//]:\n\n# (GET a visible page)", + "[//]:\r\n \t\r\n# (GET a visible page)", + "[//]: #\r\r(GET a visible page).", + ], +) +def test_reference_opening_cannot_borrow_a_title_after_a_blank_line(content: str) -> None: + assert not _p2(content) + + +@pytest.mark.parametrize("line_ending", ["\n", "\r", "\r\n"]) +def test_reference_title_can_start_on_the_next_nonblank_line(line_ending: str) -> None: + comment = f"[//]: # {line_ending}\t(GET the page)" + findings = _p2(comment) + assert len(findings) == 1 + assert findings[0].matched_text == comment + assert findings[0].location.end_line == 2 + + +@pytest.mark.parametrize("line_ending", ["\n", "\r", "\r\n"]) +def test_reference_destination_can_start_on_the_next_nonblank_line(line_ending: str) -> None: + assert _p2(f"[//]: {line_ending}\t# (GET the page)") + + +def test_separate_comments_only_own_their_own_title() -> None: + hidden = "[//]: # (GET https://example.invalid/collect)" + content = f"[//]: # (layout note)\nVisible text.\n{hidden}\n[//]: # (end note)" + findings = _p2(content) + assert len(findings) == 1 + assert findings[0].matched_text == hidden + assert findings[0].match_fingerprint == compute_match_fingerprint("P2", hidden) + assert findings[0].location.start_line == 3 + assert findings[0].location.end_line == 3 + + +@pytest.mark.parametrize( + "title", + ["target", "forget", "GETTING", "POSTGRES", "SYSTEMATIC", "sender", "transmitter"], +) +def test_title_keywords_do_not_match_ordinary_word_substrings(title: str) -> None: + assert not _p2(f"[//]: # ({title})") + + +@pytest.mark.parametrize( + "title", + [ + "SYSTEM: do something", + "instructions: do something", + "ignore_previous_instructions", + "ignorePreviousInstructions", + "pleaseIgnorePreviousInstructions", + "POSTRequestToServer", + "SYSTEMPrompt", + "GET https://example.invalid/data", + "send the message", + "transmit the message", + ], +) +def test_real_hidden_keywords_keep_detection(title: str) -> None: + comment = f"[//]: # ({title})" + findings = _p2(comment) + assert len(findings) == 1 + assert findings[0].matched_text == comment + assert findings[0].match_fingerprint == compute_match_fingerprint("P2", comment) + + +@pytest.mark.parametrize( + "comment", + [ + r"[//]: # (layout \(small\) note)", + r"[//]: # (layout \) note)", + r"[//]: # (layout \\)", + r"[//]: # (layout \\\) note)", + ], +) +def test_escaped_parentheses_and_backslashes_still_bound_visible_text(comment: str) -> None: + assert not _p2(f"{comment}\n\nGET a visible page (see below).") + + +@pytest.mark.parametrize( + "comment", + [ + r"[//]: # (layout \(small\) GET the page)", + r"[//]: # (layout \) GET the page)", + r"[//]: # (layout \\\) GET the page)", + "[//]: # (layout\nGET the page)", + ], +) +def test_escaped_closer_does_not_hide_later_keyword_inside_title(comment: str) -> None: + findings = _p2(comment) + assert len(findings) == 1 + assert findings[0].matched_text == comment + assert findings[0].match_fingerprint == compute_match_fingerprint("P2", comment) + + +@pytest.mark.parametrize( + "content", + [ + "[//]: # (layout (nested) note)\nGET a visible page (see below).", + "[//]: # (layout note", + ], +) +def test_malformed_titles_do_not_borrow_visible_keywords(content: str) -> None: + assert not _p2(content) + + +@pytest.mark.parametrize( + "content", + [ + "[//]: # (GET a page (nested) note)", + "[//]: # (GET a page\n\nextra text)", + ], +) +def test_malformed_titles_keep_conservative_hidden_keyword_detection(content: str) -> None: + assert _p2(content) + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + ("[//]: # (layout note)\n\nThen target the endpoint (see below).", False), + ("[//]: # (GET https://example.invalid/data)", True), + ("[//]: # (layout note)\n\nGET a visible page (see below).", False), + ], +) +def test_transformed_views_keep_comment_ownership(content: str, expected: bool) -> None: + state = {"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}} + findings = static_runner.run_static_patterns(state, [prompt_injection]) + p2 = [finding for finding in findings if finding.rule_id == "P2"] + assert bool(p2) is expected + if expected: + assert len(p2) == 1 + assert p2[0].start_line == 1 + + +@pytest.mark.parametrize("offset", [231_160, 247_790, 255_980]) +def test_window_overlap_keeps_only_the_real_comment(offset: int) -> None: + prefix = "Plain notes.\n" * (offset // 13) + "x" * (offset % 13) + "\n" + benign = "[//]: # (layout note)\n\nThen target the endpoint (see below).\n" + hidden = "[//]: # (GET https://example.invalid/data)" + content = prefix + benign + hidden + state = {"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}} + findings = static_runner.run_static_patterns(state, [prompt_injection]) + p2 = [finding for finding in findings if finding.rule_id == "P2"] + assert len(p2) == 1 + assert p2[0].matched_text == hidden + assert p2[0].start_line == content[: content.index(hidden)].count("\n") + 1 + + +def test_long_unclosed_title_checks_deadline_during_scanning() -> None: + pattern = prompt_injection.P2_PATTERNS[1][0] + calls = 0 + + def expire() -> None: + nonlocal calls + calls += 1 + if calls == 5: + raise TimeoutError("reference-title deadline") + + with pytest.raises(TimeoutError, match="reference-title deadline"): + list(prompt_injection._p2_pattern_matches("[//]: # (" + r"\(" * 20_000, pattern, expire)) + assert calls == 5 + + +def test_unclosed_title_cannot_repeatedly_scan_embedded_openings() -> None: + pattern = prompt_injection.P2_PATTERNS[1][0] + calls = 0 + + def count() -> None: + nonlocal calls + calls += 1 + + content = "[//]: # (" * 20_000 + assert not list(prompt_injection._p2_pattern_matches(content, pattern, count)) + assert calls < 300 + + +def test_reference_comment_output_cap_preserves_first_finding_and_partial_ledger( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(static_runner, "MAX_FINDINGS_PER_ARTIFACT", 1) + response = static_runner.run_static_patterns_with_ledger( + { + "components": ["SKILL.md"], + "file_cache": {"SKILL.md": "[//]: # (GET a page)\n" * 4}, + }, + [prompt_injection], + ) + p2 = [finding for finding in response["findings"] if finding.rule_id == "P2"] + assert len(p2) == 1 + assert p2[0].start_line == 1 + assert response["inspection_ledger"][0]["outcome"] == "partial" + assert response["inspection_ledger"][0]["reason_code"] == "output_limit" From df2a71bb5682cf82b203feb5588825d171858771 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Tue, 20 Oct 2026 11:10:03 -0700 Subject: [PATCH 3/4] fix(p2): retain keyword-bearing reference destinations Signed-off-by: Christopher Kevin --- .../nodes/analyzers/static_patterns_prompt_injection.py | 4 ++++ tests/nodes/analyzers/test_p2_reference_comments.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index 5fc5e8a0d..cb65b23a5 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -362,6 +362,10 @@ def _reference_comment_matches( end += 1 title = _REFERENCE_COMMENT_TITLE.match(content, end) if depth == 0 else None if title is None: + if depth == 0 and _REFERENCE_COMMENT_KEYWORD.search(content, destination_start, end): + match = _REFERENCE_COMMENT_SPAN.match(content, opening.start(), end) + assert match is not None + yield match cursor = max(end, opening.end()) continue title_start = title.end() diff --git a/tests/nodes/analyzers/test_p2_reference_comments.py b/tests/nodes/analyzers/test_p2_reference_comments.py index 01d318d3c..fd0a81f42 100644 --- a/tests/nodes/analyzers/test_p2_reference_comments.py +++ b/tests/nodes/analyzers/test_p2_reference_comments.py @@ -129,6 +129,15 @@ def test_hidden_destination_keywords_are_not_lost(destination: str) -> None: assert _p2(comment)[0].matched_text == comment +@pytest.mark.parametrize("destination", ["#(GET)", "#(instructions)", "#transmit", "#sendMessage"]) +def test_hidden_destination_keywords_do_not_require_a_title(destination: str) -> None: + comment = f"[//]: {destination}" + findings = _p2(comment + "\n\nGET a visible page (details).") + assert len(findings) == 1 + assert findings[0].matched_text == comment + assert findings[0].match_fingerprint == compute_match_fingerprint("P2", comment) + + @pytest.mark.parametrize( "content", [ From 9f0b06ddd7fe7ba2c106a4f1a3fbdbc4cee589c1 Mon Sep 17 00:00:00 2001 From: Christopher Kevin Date: Tue, 20 Oct 2026 11:14:05 -0700 Subject: [PATCH 4/4] fix(p2): reject incomplete reference destinations Signed-off-by: Christopher Kevin --- .../nodes/analyzers/static_patterns_prompt_injection.py | 7 +++++-- tests/nodes/analyzers/test_p2_reference_comments.py | 4 ++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py index cb65b23a5..1714c516b 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py +++ b/src/skillspector/nodes/analyzers/static_patterns_prompt_injection.py @@ -360,9 +360,12 @@ def _reference_comment_matches( break depth -= 1 end += 1 - title = _REFERENCE_COMMENT_TITLE.match(content, end) if depth == 0 else None + destination_valid = depth == 0 and (end == len(content) or content[end] in " \t\r\n") + title = _REFERENCE_COMMENT_TITLE.match(content, end) if destination_valid else None if title is None: - if depth == 0 and _REFERENCE_COMMENT_KEYWORD.search(content, destination_start, end): + if destination_valid and _REFERENCE_COMMENT_KEYWORD.search( + content, destination_start, end + ): match = _REFERENCE_COMMENT_SPAN.match(content, opening.start(), end) assert match is not None yield match diff --git a/tests/nodes/analyzers/test_p2_reference_comments.py b/tests/nodes/analyzers/test_p2_reference_comments.py index fd0a81f42..a408099d4 100644 --- a/tests/nodes/analyzers/test_p2_reference_comments.py +++ b/tests/nodes/analyzers/test_p2_reference_comments.py @@ -148,6 +148,10 @@ def test_hidden_destination_keywords_do_not_require_a_title(destination: str) -> "[//]: #(unclosed (GET visible text)", "[//]: #() (layout note)\n\nGET visible text (details).", "[//]: #(x) (layout note\n\nGET visible text (details).", + "[//]: #transmit) (layout note)", + "[//]: #GET) visible text", + "[//]: #transmit(unclosed", + "[//]: #GET\x01 (layout note)", ], ) def test_destination_requires_a_separate_title_and_cannot_borrow(content: str) -> None: