From 011c792a0442b147ce39727928d05c37a95f7a49 Mon Sep 17 00:00:00 2001 From: elliottwaves-20 Date: Fri, 9 Oct 2026 20:37:54 +0200 Subject: [PATCH 1/2] fix(analyzer): keep checking command lines inside a word that spans lines A quote in a heredoc body, a shell comment or Markdown prose can pair with a quote on a later command line. The command-word scan then parsed one word across those lines and skipped every candidate inside it, so a runtime command such as cat < Signed-off-by: elliottwaves-20 --- .../analyzers/static_patterns_tool_misuse.py | 40 +++++++-- .../analyzers/test_multiline_word_shadow.py | 87 +++++++++++++++++++ 2 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 tests/nodes/analyzers/test_multiline_word_shadow.py diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 0d687a77b..9449c3c90 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -514,6 +514,14 @@ def _is_shell_command_word_start(content: str, start: int) -> bool: return False +def _is_shell_command_position(content: str, start: int) -> bool: + """Return whether ``start`` opens a line or follows a command separator.""" + cursor = start - 1 + while cursor >= 0 and content[cursor] in " \t": + cursor -= 1 + return cursor < 0 or content[cursor] in "\n\r;|&(" + + def _has_quoted_assignment_prefix(content: str, start: int) -> bool: """Return whether a quoted candidate starts with a shell assignment name.""" if start >= len(content) or content[start] not in "'\"": @@ -1980,6 +1988,11 @@ def _has_shell_command_word_exhaustion( which reparses no string. """ parsed_through = 0 + # A word that spans lines may pair a heredoc, comment or prose quote with + # a later command line. Main skips the candidates inside such a word, so + # they are still checked here, but they never change what later + # candidates are skipped or owned. + shadow_through = 0 # Completed words own closing quotes, never executable expansion starts. # Inner commands remain independent candidates; never suppress their bodies. owned_word_positions: set[int] = set() @@ -2008,6 +2021,9 @@ def _has_shell_command_word_exhaustion( continue if _has_quoted_assignment_prefix(content, start): continue + shadowed = start < shadow_through + if shadowed and not _is_shell_command_position(content, start): + continue candidate_word_positions: set[int] = set() parsed = _parse_shell_command_word( content, @@ -2044,6 +2060,9 @@ def _has_shell_command_word_exhaustion( check_runtime=check_runtime, ): return True + if shadowed: + # The spanning word already closed this region's quotes. + continue # An unclosed expansion or quote can consume the rest of the # artifact. Once that unresolved span exceeds the command-word # budget, treating it as clean would turn malformed, deeply nested @@ -2068,7 +2087,7 @@ def _has_shell_command_word_exhaustion( or _SHELL_COMMENT_START_RE.search(content, content.rfind("\n", 0, start) + 1, start) is not None ) - if not confined_word: + if not confined_word and not shadowed: # A quote that opens a runtime-selected word stays an independent # candidate, so a mis-paired claim cannot hide its operands. owned_word_positions.update( @@ -2088,12 +2107,19 @@ def _has_shell_command_word_exhaustion( is not None ) if ( - not parsed.dynamic - or "$" not in raw_word - or not any(marker in raw_word for marker in ("$(", "`")) - or simple_backtick_parameter - ) and not (assignment_quote and confined_word): - parsed_through = max(parsed_through, parsed.end) + ( + not parsed.dynamic + or "$" not in raw_word + or not any(marker in raw_word for marker in ("$(", "`")) + or simple_backtick_parameter + ) + and not (assignment_quote and confined_word) + and not shadowed + ): + if python_source is None and ("\n" in raw_word or "\r" in raw_word): + shadow_through = max(shadow_through, parsed.end) + else: + parsed_through = max(parsed_through, parsed.end) if assignment_quote: # An assignment value never names the command, and main never # parsed this position, so it only claims ownership. diff --git a/tests/nodes/analyzers/test_multiline_word_shadow.py b/tests/nodes/analyzers/test_multiline_word_shadow.py new file mode 100644 index 000000000..f7b7ed57a --- /dev/null +++ b/tests/nodes/analyzers/test_multiline_word_shadow.py @@ -0,0 +1,87 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A word that spans lines never hides a later command line (#694).""" + +from __future__ import annotations + +import pytest + +from skillspector.inspection_ledger import LedgerOutcome +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm_module +from skillspector.nodes.analyzers import static_runner + +_PADDING = "\n# ordinary padding\n" * 400 +_TAIL = '\nE\n$CMD"" -rf / # "\n' + + +def _exhausted(content: str, file_type: str = "shell") -> bool: + return tm_module.has_bounded_parse_exhaustion( + content, lambda: None, file_type=file_type, complete_context=True + ) + + +@pytest.mark.parametrize( + ("content", "file_type"), + [ + ('# x "\n$CMD"" -rf / # "\n', "shell"), + ('cat < None: + # A quote in a heredoc body, a comment or prose may pair with a quote on + # a later command line. The word between them must not hide that line. + assert _exhausted(content, file_type) + assert _exhausted(content + _PADDING, file_type) + + +def test_heredoc_quote_cannot_hide_a_runtime_rm_at_scan_level() -> None: + script = ( + '#!/bin/sh\nCMD="${TOOL:-rm}"\ncat < None: + content = 'echo "line1\nline2 $HOME"\nls -la\n' + assert not _exhausted(content) + assert not _exhausted(content + _PADDING) + + +def test_dynamic_multiline_word_matches_main() -> None: + # Main never advanced past a dynamic ``$(`` word, so its inner commands + # were already independent candidates. + content = 'echo "$(date)\nnext"\nls\n' + assert not _exhausted(content) + assert not _exhausted(content + _PADDING) + + +def test_python_source_keeps_main_frontier() -> None: + content = 'x = """he said "hi\nmore"""\nprint(x)\n' + assert not _exhausted(content, "python") + assert not _exhausted(content + _PADDING, "python") From 8dd0d6a80c496f3288d60bbae9cf01a514ad032c Mon Sep 17 00:00:00 2001 From: elliottwaves-20 Date: Tue, 13 Oct 2026 12:03:57 +0200 Subject: [PATCH 2/2] fix(analyzer): check runtime words after a prefix word in a shadow region Inside a shadow region only command positions (line start, ;, |, &, () were checked, so a runtime command after a prefix word stayed hidden: sudo $CMD"" -rf / # ", then $CMD, { $CMD, env A=1 $CMD, sudo -u root $CMD, and the backtick form sudo `...`. Each is partial on main when it stands alone and became complete after a heredoc whose prose quote pairs across lines. A shadowed candidate that opens a runtime word ($, "$, or a single backtick, bare or after ") now keeps main's word-start check. A run of backticks such as a Markdown fence never opens a substitution, so the parse-work bound in test_json_owned_runtime_bounds.py still holds. Refs #813 Co-Authored-By: Claude Opus 5.5 Signed-off-by: elliottwaves-20 --- .../analyzers/static_patterns_tool_misuse.py | 23 ++++++++++++++++++- .../analyzers/test_multiline_word_shadow.py | 20 ++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 9449c3c90..99876f0d5 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -522,6 +522,19 @@ def _is_shell_command_position(content: str, start: int) -> bool: return cursor < 0 or content[cursor] in "\n\r;|&(" +def _opens_runtime_word(content: str, start: int) -> bool: + """Return whether ``start`` opens a ``$`` or single-backtick word, bare or in ``"``.""" + cursor = start + 1 if content.startswith('"', start) else start + if content.startswith("$", cursor): + return True + # A backtick run such as a Markdown fence never opens a substitution. + return ( + content.startswith("`", cursor) + and not content.startswith("``", cursor) + and (cursor == 0 or content[cursor - 1] != "`") + ) + + def _has_quoted_assignment_prefix(content: str, start: int) -> bool: """Return whether a quoted candidate starts with a shell assignment name.""" if start >= len(content) or content[start] not in "'\"": @@ -2022,7 +2035,15 @@ def _has_shell_command_word_exhaustion( if _has_quoted_assignment_prefix(content, start): continue shadowed = start < shadow_through - if shadowed and not _is_shell_command_position(content, start): + # A runtime-selected command may follow any prefix word (``sudo -u + # root``, ``then``, ``{``, ``env A=1``), so ``$``- and backtick-led + # candidates keep main's word-start check; other words must open a + # command position. + if ( + shadowed + and not _opens_runtime_word(content, start) + and not _is_shell_command_position(content, start) + ): continue candidate_word_positions: set[int] = set() parsed = _parse_shell_command_word( diff --git a/tests/nodes/analyzers/test_multiline_word_shadow.py b/tests/nodes/analyzers/test_multiline_word_shadow.py index f7b7ed57a..f30e6b09f 100644 --- a/tests/nodes/analyzers/test_multiline_word_shadow.py +++ b/tests/nodes/analyzers/test_multiline_word_shadow.py @@ -33,6 +33,16 @@ def _exhausted(content: str, file_type: str = "shell") -> bool: ('cat < bool: "heredoc-backtick", "heredoc-carriage-return", "markdown-prose", + "after-sudo", + "after-sudo-option", + "after-then", + "after-brace", + "after-bang", + "after-env-assignment", + "after-xargs-option", + "quoted-after-sudo", + "backtick-after-sudo", + "quoted-backtick-after-then", ], ) def test_quote_spanning_lines_cannot_hide_a_later_command(content: str, file_type: str) -> None: