diff --git a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py index 680e3ca8a..6d8876a22 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py +++ b/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py @@ -2488,7 +2488,6 @@ def has_bounded_parse_exhaustion( structural_quote_closers = None structural_quote_openers = None json_strings: list[tuple[int, int]] = [] - raw_content = content if file_type == "markdown": if complete_context: json_strings = validated_json_string_spans(content, check_runtime) @@ -2524,11 +2523,14 @@ def has_bounded_parse_exhaustion( # whole-content parser reaches its structural opener. Recover each proven # string independently, without bypassing that parser's forward watermark # and reparsing overlapping suffixes. These raw spans are disjoint and - # bounded by JSON validation; their projection retains both outer quotes - # and escaped bytes while preserving ordinary inline-code documentation. + # bounded by JSON validation. Reuse the whole-document projection so each + # string retains its original fenced/literal or inline-code ownership. + # Reinterpreting a string as standalone Markdown could mask shell backticks + # that were literal inside its surrounding code fence. The projection keeps + # source offsets, outer JSON quotes and escaped bytes unchanged. for start, end in json_strings: check_runtime() - projected = _markdown_shell_text(raw_content[start:end], check_runtime) + projected = content[start:end] if _has_shell_command_word_exhaustion( projected, check_runtime, diff --git a/tests/nodes/analyzers/test_json_container_indentation.py b/tests/nodes/analyzers/test_json_container_indentation.py index 338529c41..2887e76c8 100644 --- a/tests/nodes/analyzers/test_json_container_indentation.py +++ b/tests/nodes/analyzers/test_json_container_indentation.py @@ -340,9 +340,16 @@ def test_tab_container_keeps_escaped_quotes_and_real_instruction_source( "> ```", json.dumps(["Use `$(hostname).example` for the host name."]), ), + # Code-fence contents cannot establish Markdown inline ownership. + False, + LedgerReason.STATIC_PARSE_LIMIT, + id="literal-json-fenced-hostname", + ), + pytest.param( + json.dumps(["Use `$(hostname).example` for the host name."]), True, None, - id="benign-json-inline-hostname", + id="benign-json-standalone-inline-hostname", ), ] diff --git a/tests/nodes/analyzers/test_json_owned_runtime_bounds.py b/tests/nodes/analyzers/test_json_owned_runtime_bounds.py index 41682b060..8fba43cb0 100644 --- a/tests/nodes/analyzers/test_json_owned_runtime_bounds.py +++ b/tests/nodes/analyzers/test_json_owned_runtime_bounds.py @@ -167,14 +167,30 @@ def test_owned_json_literal_and_data_values_remain_complete(value: str) -> None: @pytest.mark.parametrize( - "source", + "source,complete", [ - pytest.param(json.dumps([_INLINE_HOST_DOCUMENTATION]), id="standalone"), - pytest.param(_quote_fence([_INLINE_HOST_DOCUMENTATION]), id="space-quote"), + pytest.param(json.dumps([_INLINE_HOST_DOCUMENTATION]), True, id="standalone"), + pytest.param(_quote_fence([_INLINE_HOST_DOCUMENTATION]), False, id="space-quote"), ], ) -def test_owned_json_inline_host_documentation_remains_complete(source: str) -> None: - _assert_complete(source) +def test_owned_json_host_documentation_retains_original_delimiter_ownership( + source: str, complete: bool +) -> None: + if complete: + _assert_complete(source) + else: + # A JSON code fence cannot prove Markdown inline ownership inside its + # strings. Literal backticks around a runtime-selected executable must + # remain conservative even when the surrounding text resembles prose. + result = static_runner.run_static_patterns_with_ledger( + {"components": ["SKILL.md"], "file_cache": {"SKILL.md": source}}, [tm_module] + ) + assert result["findings"] == [] + assert any( + row["outcome"] is LedgerOutcome.PARTIAL + and row["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT + for row in result["inspection_ledger"] + ) def _legacy_source(value: str, embedded: bool) -> str: diff --git a/tests/nodes/analyzers/test_json_recovery_context.py b/tests/nodes/analyzers/test_json_recovery_context.py new file mode 100644 index 000000000..9882606e8 --- /dev/null +++ b/tests/nodes/analyzers/test_json_recovery_context.py @@ -0,0 +1,120 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Recover JSON commands without changing their original Markdown ownership.""" + +from __future__ import annotations + +import asyncio +import json +from pathlib import Path + +import pytest +from typer.testing import CliRunner + +from skillspector.cli import app +from skillspector.inspection_ledger import LedgerOutcome, LedgerReason +from skillspector.mcp_server import run_scan +from skillspector.nodes.analyzers import static_patterns_tool_misuse as tm_module +from skillspector.nodes.analyzers import static_runner +from tests.nodes.analyzers.test_documentation_reconstruction import _assert_llm_mode +from tests.nodes.analyzers.test_documentation_reconstruction import ( + successful_llm_transport as successful_llm_transport, +) + +_RUNTIME_COMMAND = "`$(resolve_tool).example` -rf /" + + +def _json_fence(value: str, padded: bool, marker: str = "~~~", container: str = "plain") -> str: + # These are inert scanner inputs. The surrounding entries make an earlier + # shell parse consume the command, requiring recovery of its JSON string. + values = ["`ordinary ", value, "`"] if padded else [value] + opener, prefix = { + "plain": ("", ""), + "quote": ("> ", "> "), + "list": ("- ", " "), + "tab-list": ("-\t", "\t"), + }[container] + return f"{opener}{marker}json\n{prefix}{json.dumps(values)}\n{prefix}{marker}\n" + + +@pytest.mark.parametrize("container", ["plain", "quote", "list", "tab-list"]) +@pytest.mark.parametrize("marker", ["~~~", "```"], ids=["tildes", "backticks"]) +@pytest.mark.parametrize("padded", [False, True], ids=["single-entry", "multiple-entries"]) +def test_json_recovery_preserves_literal_fence_context( + container: str, marker: str, padded: bool +) -> None: + content = _json_fence(_RUNTIME_COMMAND, padded, marker, container) + assert len(tm_module.validated_json_string_spans(content, lambda: None)) == (3 if padded else 1) + result = static_runner.run_static_patterns_with_ledger( + {"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}}, [tm_module] + ) + assert any( + row["outcome"] is LedgerOutcome.PARTIAL + and row["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT + and row["analyzer_id"] == "static_patterns_tool_misuse" + and row["path"] == "SKILL.md" + for row in result["inspection_ledger"] + ) + assert result["findings"] == [] + + +@pytest.mark.parametrize("padded", [False, True], ids=["single-entry", "multiple-entries"]) +@pytest.mark.parametrize( + "value,complete", + [ + (_RUNTIME_COMMAND, False), + ("Use `$(hostname).example` for the host name.", False), + ("Ordinary prose `example`", True), + ], + ids=["unresolved-command", "literal-runtime-in-prose", "benign-prose"], +) +@pytest.mark.parametrize("use_llm", [False, True], ids=["no-llm", "llm"]) +def test_json_recovery_preserves_cli_and_mcp_completeness_gates( + tmp_path: Path, + padded: bool, + value: str, + complete: bool, + use_llm: bool, + successful_llm_transport: list[str], +) -> None: + content = ( + "---\nname: json-command-example\n" + "description: Inspect the command stored in the JSON array.\n---\n\n" + "Inspect the command stored in the JSON array:\n\n" + _json_fence(value, padded) + ) + (tmp_path / "SKILL.md").write_text(content, encoding="utf-8") + args = ["scan", str(tmp_path), "--format", "json", "--fail-on-incomplete"] + if not use_llm: + args.append("--no-llm") + cli = CliRunner().invoke(app, args) + cli_calls = list(successful_llm_transport) + successful_llm_transport.clear() + mcp = asyncio.run(run_scan(str(tmp_path), use_llm=use_llm, output_format="json")) + mcp_calls = list(successful_llm_transport) + + assert cli.exception is None or isinstance(cli.exception, SystemExit) + assert cli.exit_code == (0 if complete else 1), cli.output + assert mcp["safe_to_install"] is complete + assert mcp["llm_used"] is use_llm + assert mcp["analysis_completeness"]["is_complete"] is complete + for report, calls in [ + (json.loads(cli.output), cli_calls), + (json.loads(mcp["report"]), mcp_calls), + ]: + _assert_llm_mode(report, use_llm, calls) + assert report["issues"] == [] + assert report["risk_assessment"]["score"] == 0 + coverage = report["analysis_completeness"] + assert coverage["execution_successful"] is True + assert coverage["is_complete"] is complete + assert report["risk_assessment"]["recommendation"] == ("SAFE" if complete else "CAUTION") + if complete: + assert coverage["ledger_exceptions"] == [] + else: + assert any( + event["reason_code"] == LedgerReason.STATIC_PARSE_LIMIT + and event["path"] == "SKILL.md" + and "static_patterns_tool_misuse" in event["analyzers"] + for event in coverage["ledger_exceptions"] + )