Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package skillspector for openSUSE:Factory checked in at 2026-10-31 23:09:19 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/skillspector (Old) and /work/SRC/openSUSE:Factory/.skillspector.new.1255 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "skillspector" Sat Oct 31 23:09:19 2026 rev:12 rq:1394073 version:2.13.2 Changes: -------- --- /work/SRC/openSUSE:Factory/skillspector/skillspector.changes 2026-10-26 10:05:51.514447927 +0100 +++ /work/SRC/openSUSE:Factory/.skillspector.new.1255/skillspector.changes 2026-10-31 23:11:40.121119571 +0100 @@ -1,0 +2,18 @@ +Thu Oct 29 10:14:59 UTC 2026 - Martin Pluskal <[email protected]> + +- Update to version 2.13.2: + * No more false parser-limit reports on bare command + wrappers (timeout, sudo, nice, xargs) at clause + boundaries in tables and prose references + * sudo -n parsed as flag; descriptor redirections + (e.g. 2>&1) count as complete; MCP batch cap + aligned with the 128-file limit + * Direct template messages on verified built-in Node + assertion imports no longer trip shell-substitution + limits; .mjs/.cjs classified consistently +- Deselect two wall-clock-bound tests on aarch64 (pre-existing, + untouched by this update): the 10 s subprocess and 1 s worker + bounds overrun slow interpreter teardown, with all detection + assertions passing. + +------------------------------------------------------------------- Old: ---- skillspector-2.13.1.tar.gz New: ---- skillspector-2.13.2.tar.gz ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ skillspector.spec ++++++ --- /var/tmp/diff_new_pack.tnXnpi/_old 2026-10-31 23:11:43.018239847 +0100 +++ /var/tmp/diff_new_pack.tnXnpi/_new 2026-10-31 23:11:43.020239930 +0100 @@ -21,7 +21,7 @@ # %%{primary_python} so it stays correct as the primary interpreter moves. %define pythons %{primary_python} Name: skillspector -Version: 2.13.1 +Version: 2.13.2 Release: 0 Summary: Security scanner for AI agent skills License: Apache-2.0 @@ -205,13 +205,25 @@ # scan finishes in under 1.0 s; aarch64 needs 1.28 s. Pure wall-clock # bound with no detection content (its zero-PE3-finding assertion holds). # +# 2.13.2 adds two more of the same wall-clock family (both pre-existing +# tests, untouched by the update; upstream x86_64 CI is green): +# * test_typescript_declaration_near_misses... spawns a fresh interpreter +# with a hardcoded 10 s timeout. The child completes all 15 assertions +# (it prints the completion marker) -- only interpreter teardown past a +# ~10 s cold import overruns the bound on aarch64. +# * test_worker_startup_has_a_separate_bound joins a spawned worker 1 s +# after startup. The worker's whole job is setting an event and writing +# a 100-byte result file -- correct by construction -- but teardown of +# the spawned cone import overruns the 1 s bound on aarch64 (cold +# "import skillspector" alone measures 9.8 s here). +# # Deselecting keeps the build gate meaningful without hiding a detection # failure: the same resource bounds are asserted deterministically by the # tests that monkeypatch the budget rather than racing it (e.g. # test_static_runtime_limit_is_reported_as_partial, # test_static_output_limit_is_reported_as_partial, and the build_context # deadline tests), and those do run. -%pytest --deselect tests/unit/test_mcp_server.py::test_mcp_stdio_initialize_registers_scan_skill --deselect tests/unit/test_baseline_mcp.py::test_stdio_mcp_preserves_compact_verdicts_and_ignores_shipped_baseline --deselect tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_github_url_allowed --deselect tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_gitlab_url_allowed --deselect tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_raw_githubusercontent_allowed --deselect tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_download_does_not_follow_redirects --deselect tests/unit/test_input_handler.py::test_validate_url_host_scp_extracts_github --deselect tests/unit/test_input_handler.py::test_scp_valid_host_clones --deselect tests/unit/test_input_handler.py::test_https_url_unchanged --deselect "tests/nodes/analyzers/test_bundled_execution_surface.py::test_discovery_parser_bounds_and_ledger_table[malformed_schema]" --deselect tests/test_bund led_execution_surface_acceptance.py::test_public_cli_exit_contract --deselect tests/test_bundled_execution_surface_acceptance.py::test_recursive_single_child_routes_execution_surfaces_from_child_root --deselect tests/test_bundled_execution_surface_acceptance.py::test_cli_fail_on_incomplete_is_opt_in --deselect tests/nodes/test_security_end_to_end.py::test_rd04_large_file_pair_detects_start_boundary_and_end --deselect tests/nodes/test_security_end_to_end.py::test_nine_case_contract_across_public_surfaces --deselect tests/nodes/test_security_remediation.py::test_five_megabyte_normalized_static_scan_stays_below_memory_ceiling --deselect tests/nodes/test_security_end_to_end.py::test_cross_window_separator_pair_across_public_surfaces --deselect tests/unit/test_patterns.py::TestPrivilegeEscalation::test_pe3_repeated_nouns_have_bounded_qualifier_cost --deselect tests/unit/test_cli_subprocess.py::test_registry_unsupported_options_stop_before_input_or_output_access --deselect tests/unit/test _cli_subprocess.py::test_registry_default_options_still_scan --deselect tests/unit/test_cli_subprocess.py::test_recursive_medium_recommendation_matches_direct_scan +%pytest --deselect tests/unit/test_mcp_server.py::test_mcp_stdio_initialize_registers_scan_skill --deselect tests/unit/test_baseline_mcp.py::test_stdio_mcp_preserves_compact_verdicts_and_ignores_shipped_baseline --deselect tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_github_url_allowed --deselect tests/unit/test_input_handler_ssrf.py::TestGitCloneSSRF::test_gitlab_url_allowed --deselect tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_raw_githubusercontent_allowed --deselect tests/unit/test_input_handler_ssrf.py::TestDownloadSSRF::test_download_does_not_follow_redirects --deselect tests/unit/test_input_handler.py::test_validate_url_host_scp_extracts_github --deselect tests/unit/test_input_handler.py::test_scp_valid_host_clones --deselect tests/unit/test_input_handler.py::test_https_url_unchanged --deselect "tests/nodes/analyzers/test_bundled_execution_surface.py::test_discovery_parser_bounds_and_ledger_table[malformed_schema]" --deselect tests/test_bund led_execution_surface_acceptance.py::test_public_cli_exit_contract --deselect tests/test_bundled_execution_surface_acceptance.py::test_recursive_single_child_routes_execution_surfaces_from_child_root --deselect tests/test_bundled_execution_surface_acceptance.py::test_cli_fail_on_incomplete_is_opt_in --deselect tests/nodes/test_security_end_to_end.py::test_rd04_large_file_pair_detects_start_boundary_and_end --deselect tests/nodes/test_security_end_to_end.py::test_nine_case_contract_across_public_surfaces --deselect tests/nodes/test_security_remediation.py::test_five_megabyte_normalized_static_scan_stays_below_memory_ceiling --deselect tests/nodes/test_security_end_to_end.py::test_cross_window_separator_pair_across_public_surfaces --deselect tests/unit/test_patterns.py::TestPrivilegeEscalation::test_pe3_repeated_nouns_have_bounded_qualifier_cost --deselect tests/unit/test_cli_subprocess.py::test_registry_unsupported_options_stop_before_input_or_output_access --deselect tests/unit/test _cli_subprocess.py::test_registry_default_options_still_scan --deselect tests/unit/test_cli_subprocess.py::test_recursive_medium_recommendation_matches_direct_scan --deselect tests/nodes/test_nested_artifacts.py::test_typescript_declaration_near_misses_are_bounded_and_stay_executable --deselect tests/test_batch_scan_security.py::test_worker_startup_has_a_separate_bound %files %license LICENSE ++++++ skillspector-2.13.1.tar.gz -> skillspector-2.13.2.tar.gz ++++++ diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/PKG-INFO new/skillspector-2.13.2/PKG-INFO --- old/skillspector-2.13.1/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/PKG-INFO 2020-02-02 01:00:00.000000000 +0100 @@ -1,6 +1,6 @@ Metadata-Version: 2.4 Name: skillspector -Version: 2.13.1 +Version: 2.13.2 Summary: SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring. Project-URL: Homepage, https://github.com/NVIDIA/skillspector Project-URL: Documentation, https://github.com/NVIDIA/skillspector#readme diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/docs/release/skillspector-2.13.2.md new/skillspector-2.13.2/docs/release/skillspector-2.13.2.md --- old/skillspector-2.13.1/docs/release/skillspector-2.13.2.md 1970-01-01 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/docs/release/skillspector-2.13.2.md 2020-02-02 01:00:00.000000000 +0100 @@ -0,0 +1,38 @@ +# SkillSpector 2.13.2 + +This patch release builds on the published v2.13.1 maintenance release and +backports three fixes for false incomplete-analysis reports. It preserves the +existing maintenance fixes, third-party dependency lock, and provider defaults. + +## Fixes + +- [#779](https://github.com/NVIDIA/SkillSpector/pull/779): Recognize command wrappers + with no wrapped command at a proven clause boundary. Markdown table cells and + ordinary source references to `timeout`, `sudo`, `nice`, and `xargs` no longer + cause false parser-limit reports. Redirections, unfinished fragments, and + commands in subsequent clauses retain their existing checks. +- [#917](https://github.com/NVIDIA/SkillSpector/pull/917): Treat `sudo -n` as a flag + and parse descriptor redirections such as `2>&1` as complete operators. Align + the MCP TP4 batch limit with its existing 128-file limit so supported inputs + are not prematurely truncated after 64 batches. Byte, token, declaration, + file, runtime, and provider-error limits remain enforced. +- [#921](https://github.com/NVIDIA/SkillSpector/pull/921): Recognize direct template + messages on verified built-in Node assertion imports, avoiding false shell + substitution limits in JavaScript helpers. Classify `.mjs` and `.cjs` files + consistently, including archive members. Raw security findings and their + source locations are preserved. Ambiguous syntax, assertion aliases or + mutations, effectful interpolation, and exceeded bounds remain conservative; + TypeScript retains its existing parser path. + +## Scope and upgrading + +The JavaScript correction is a bounded assertion-message proof, not a general +JavaScript parser or dataflow analysis. It assumes ordinary built-in assertion +semantics and does not prove the absence of arbitrary runtime mutation or +object getters. Existing findings and independent inspection limitations remain +visible; these fixes do not suppress malformed provider responses. + +Regenerate and review exact version-bound finding baselines for 2.13.2 rather +than relabeling baselines from another scanner version. The GitHub release +provides a wheel and source distribution; the publication workflow does not +publish to PyPI. diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/pyproject.toml new/skillspector-2.13.2/pyproject.toml --- old/skillspector-2.13.1/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/pyproject.toml 2020-02-02 01:00:00.000000000 +0100 @@ -4,7 +4,7 @@ [project] name = "skillspector" -version = "2.13.1" +version = "2.13.2" description = "SkillSpector: Security scanner for AI agent skills (Claude Code, Cursor, and similar). Scans skills for vulnerabilities, malicious patterns, and security risks before installation. Supports Git repos, URLs, zips, and local directories; runs static pattern checks and optional LLM semantic analysis; outputs terminal, JSON, and Markdown reports with risk scoring." readme = "README.md" license = "Apache-2.0" diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/src/skillspector/javascript_tokens.py new/skillspector-2.13.2/src/skillspector/javascript_tokens.py --- old/skillspector-2.13.1/src/skillspector/javascript_tokens.py 1970-01-01 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/src/skillspector/javascript_tokens.py 2020-02-02 01:00:00.000000000 +0100 @@ -0,0 +1,381 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bounded lexical ownership for Node assertion template-message delimiters. + +This is not a JavaScript parser or a shell-sink/dataflow model. Only a direct +diagnostic argument of one unaliased built-in assertion import may borrow +ownership. Unsupported source keeps its original conservative shell reading. +""" + +from __future__ import annotations + +import re +from collections.abc import Callable +from dataclasses import dataclass + +_MAX_SOURCE_CHARS = 1_000_000 +_MAX_TOKENS = 100_000 +_MAX_NESTING = 128 +_LINE_ENDS = "\r\n\u2028\u2029" +_IDENTIFIER = re.compile(r"[A-Za-z_$][A-Za-z0-9_$]*") +_TEMPLATE_VALUE = re.compile( + r"\s*[A-Za-z_$][A-Za-z0-9_$]*(?:\s*\.\s*[A-Za-z_$][A-Za-z0-9_$]*)*\s*\Z" +) +_NODE_SHEBANG = re.compile(r"#![ \t]*(?:\S*/)?(?:env[ \t]+)?node(?:js)?(?=[ \t\r\n]|$)") +_ASSERT_MODULES = frozenset({"node:assert", "node:assert/strict"}) +_ASSERT_MODULE_ALIASES = _ASSERT_MODULES | {"assert", "assert/strict"} +_MESSAGE_ARGUMENTS = { + "ok": 2, + "equal": 3, + "notEqual": 3, + "strictEqual": 3, + "notStrictEqual": 3, + "deepEqual": 3, + "notDeepEqual": 3, + "deepStrictEqual": 3, + "notDeepStrictEqual": 3, + "match": 3, + "doesNotMatch": 3, + "throws": 3, + "doesNotThrow": 3, +} +_EXPRESSION_PREFIX_WORDS = frozenset( + { + "return", + "throw", + "case", + "delete", + "void", + "typeof", + "new", + "yield", + "await", + "in", + "instanceof", + "else", + "do", + } +) +_OPERATORS = frozenset( + { + "===", + "!==", + ">>>", + "**=", + "&&=", + "||=", + "??=", + "...", + "=>", + "==", + "!=", + "<=", + ">=", + "&&", + "||", + "??", + "++", + "--", + "**", + "+=", + "-=", + "*=", + "%=", + "&=", + "|=", + "^=", + "<<", + ">>", + "?.", + } +) + + +@dataclass(frozen=True, slots=True) +class _Token: + kind: str + start: int + end: int + depth: int + interpolation_names: tuple[str, ...] = () + + +def _tokens( + content: str, check_runtime: Callable[[], None] +) -> tuple[list[_Token], dict[int, int]] | None: + """Tokenize the limited lexical forms needed to prove message boundaries.""" + tokens: list[_Token] = [] + pairs: dict[int, int] = {} + stack: list[tuple[str, int]] = [] + cursor = 0 + expression_start = True + next_check = 0 + + def check(position: int) -> None: + nonlocal next_check + if position >= next_check: + check_runtime() + next_check = position + 4096 + + if content.startswith("#!"): + if _NODE_SHEBANG.match(content) is None: + return None + cursor = content.find("\n") + if cursor < 0: + return None + while cursor < len(content): + check(cursor) + character = content[cursor] + if character.isspace(): + cursor += 1 + continue + if character == "\x00" or len(tokens) >= _MAX_TOKENS: + return None + if content.startswith("//", cursor): + while cursor < len(content) and content[cursor] not in _LINE_ENDS: + check(cursor) + cursor += 1 + continue + if content.startswith("/*", cursor): + cursor += 2 + while cursor < len(content) and not content.startswith("*/", cursor): + check(cursor) + cursor += 1 + if cursor == len(content): + return None + cursor += 2 + continue + start = cursor + kind = "punctuation" + interpolation_names: list[str] = [] + if character in "'\"`": + kind = "template" if character == "`" else "string" + cursor += 1 + while cursor < len(content): + check(cursor) + current = content[cursor] + if current == "\\": + cursor += 2 + if cursor <= len(content) and content[cursor - 1 : cursor + 1] == "\r\n": + cursor += 1 + continue + if current == character: + cursor += 1 + break + if kind == "string" and current in _LINE_ENDS: + return None + if kind == "template" and content.startswith("${", cursor): + value_start = cursor + 2 + cursor = value_start + while cursor < len(content) and content[cursor] != "}": + check(cursor) + # Calls, computed members, comments, nested templates, + # and other effectful expressions never gain ownership. + if not content[cursor].isascii(): + return None + cursor += 1 + value = content[value_start:cursor] + if cursor == len(content) or _TEMPLATE_VALUE.fullmatch(value) is None: + return None + interpolation_names.extend(_IDENTIFIER.findall(value)) + cursor += 1 + continue + cursor += 1 + else: + return None + expression_start = False + elif character == "/": + # Division versus a statement-level regexp needs more grammar. + # Only a clear expression prefix establishes regexp ownership. + if not expression_start: + return None + kind = "regexp" + cursor += 1 + in_class = False + while cursor < len(content): + check(cursor) + current = content[cursor] + if current in _LINE_ENDS: + return None + if current == "\\": + if cursor + 1 == len(content) or content[cursor + 1] in _LINE_ENDS: + return None + cursor += 2 + continue + if current == "[": + if in_class: + # Unicode-set nested classes require a different grammar. + return None + in_class = True + elif current == "]": + in_class = False + elif current == "/" and not in_class: + cursor += 1 + break + cursor += 1 + else: + return None + flags_start = cursor + while cursor < len(content) and content[cursor].isascii() and content[cursor].isalpha(): + check(cursor) + cursor += 1 + flags = content[flags_start:cursor] + if len(set(flags)) != len(flags) or set(flags) - set("dgimsuy"): + return None + expression_start = False + elif (identifier := _IDENTIFIER.match(content, cursor)) is not None: + kind = "identifier" + cursor = identifier.end() + previous = content[tokens[-1].start : tokens[-1].end] if tokens else "" + expression_start = identifier[0] in _EXPRESSION_PREFIX_WORDS and previous not in { + ".", + "?.", + } + elif character.isascii() and character.isdigit(): + kind = "number" + cursor += 1 + while cursor < len(content) and content[cursor] in "0123456789abcdefABCDEFxXoObBn._": + check(cursor) + cursor += 1 + expression_start = False + elif character in "()[]{};,:.?=<>!~+-*%&|^": + width = 1 + if content[cursor : cursor + 3] in _OPERATORS: + width = 3 + elif content[cursor : cursor + 2] in _OPERATORS: + width = 2 + cursor += width + operator = content[start:cursor] + if operator == "!" and not expression_start: + # TypeScript's postfix assertion leaves division possible. + return None + if operator in "([{": + if len(stack) >= _MAX_NESTING: + return None + stack.append((operator, len(tokens))) + elif operator in ")]}": + if not stack or stack[-1][0] != {"}": "{", ")": "(", "]": "["}[operator]: + return None + _, opening = stack.pop() + pairs[opening] = len(tokens) + expression_start = operator not in {")", "]", "}", ".", "?.", "++", "--"} + else: + # Escaped/Unicode identifiers, JSX, and unknown lexical forms are + # not proof of a Node assertion receiver or template boundary. + return None + tokens.append(_Token(kind, start, cursor, len(stack), tuple(interpolation_names))) + check_runtime() + return None if stack else (tokens, pairs) + + +def assertion_template_shell_text(content: str, check_runtime: Callable[[], None]) -> str: + """Project only proven Node assertion message delimiters for shell completeness. + + Literal bodies and interpolations remain byte-for-byte present. Findings + must still be produced from the original source. Unknown calls, tagged or + concatenated templates, ambiguous imports, and receiver mutation/shadowing + receive no exemption. Runtime interruption propagates to the caller. + + This assumes the standard built-in module. It cannot prove the absence of + arbitrary runtime monkeypatching or object getters. + """ + if len(content) > _MAX_SOURCE_CHARS or "node:assert" not in content or "`" not in content: + return content + parsed = _tokens(content, check_runtime) + if parsed is None: + return content + tokens, pairs = parsed + values = [content[token.start : token.end] for token in tokens] + imports: list[tuple[str, int, int]] = [] + for index, token in enumerate(tokens): + if index % 256 == 0: + check_runtime() + if token.kind == "identifier" and ( + values[index] == "require" + or values[index] == "import" + and values[index + 1 : index + 2] == ["("] + ): + return content + if token.kind == "string" and index and values[index - 1] in {"import", "from"}: + # Escaped module names can alias the same mutable built-in object. + if "\\" in values[index]: + return content + if values[index] != "import" or token.depth: + continue + following = values[index : index + 7] + if len(following) >= 5 and following[2] == "from" and following[4] == ";": + binding_index, module_index = index + 1, index + 3 + elif ( + len(following) == 7 + and following[1:3] == ["*", "as"] + and following[4] == "from" + and following[6] == ";" + ): + binding_index, module_index = index + 3, index + 5 + else: + continue + if ( + tokens[binding_index].kind == "identifier" + and tokens[module_index].kind == "string" + and values[module_index][1:-1] in _ASSERT_MODULES + ): + imports.append((values[binding_index], binding_index, module_index)) + if len(imports) != 1: + return content + binding, binding_index, module_index = imports[0] + if any( + token.kind == "string" + and index != module_index + and values[index][1:-1] in _ASSERT_MODULE_ALIASES + or binding in token.interpolation_names + for index, token in enumerate(tokens) + ): + return content + delimiters: list[int] = [] + for index, token in enumerate(tokens): + if index % 256 == 0: + check_runtime() + if token.kind != "identifier" or values[index] != binding or index == binding_index: + continue + if (index and values[index - 1] in {".", "?.", "new"}) or values[index + 1 : index + 2] != [ + "." + ]: + return content + if ( + index + 3 >= len(tokens) + or values[index + 2] not in _MESSAGE_ARGUMENTS + or values[index + 3] != "(" + ): + return content + opening = index + 3 + closing = pairs.get(opening) + if closing is None: + return content + arguments: list[tuple[int, int]] = [] + begin = cursor = opening + 1 + while cursor < closing: + check_runtime() + if cursor in pairs: + cursor = pairs[cursor] + 1 + continue + if values[cursor] == ",": + arguments.append((begin, cursor)) + begin = cursor + 1 + cursor += 1 + if begin < closing: + arguments.append((begin, closing)) + if len(arguments) != _MESSAGE_ARGUMENTS[values[index + 2]] or any( + start == end or values[start] == "..." for start, end in arguments + ): + continue + start, end = arguments[-1] + if end == start + 1 and tokens[start].kind == "template": + delimiters.extend((tokens[start].start, tokens[start].end - 1)) + if not delimiters: + return content + output = list(content) + for position in delimiters: + output[position] = " " + check_runtime() + return "".join(output) diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/src/skillspector/nested_artifacts.py new/skillspector-2.13.2/src/skillspector/nested_artifacts.py --- old/skillspector-2.13.1/src/skillspector/nested_artifacts.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/src/skillspector/nested_artifacts.py 2020-02-02 01:00:00.000000000 +0100 @@ -770,6 +770,8 @@ ".toml": "toml", ".txt": "text", ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", ".ts": "typescript", ".rb": "ruby", ".go": "go", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py new/skillspector-2.13.2/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py --- old/skillspector-2.13.1/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/src/skillspector/nodes/analyzers/mcp_tool_poisoning.py 2020-02-02 01:00:00.000000000 +0100 @@ -78,7 +78,7 @@ TP4_MAX_TOTAL_CODE_BYTES = 4 * 1024 * 1024 TP4_MAX_TOTAL_INPUT_BYTES = 4 * 1024 * 1024 TP4_MAX_FILE_CODE_BYTES = 1024 * 1024 -TP4_MAX_BATCHES = 64 +TP4_MAX_BATCHES = TP4_MAX_FILES TP4_MAX_BATCH_INPUT_TOKENS = 32_000 TP4_MIN_CODE_TOKENS = 64 TP4_MAX_DECLARATION_CHARS = 16_384 diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py --- old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_patterns_tool_misuse.py 2020-02-02 01:00:00.000000000 +0100 @@ -41,6 +41,7 @@ normalized_security_view, security_text_views, ) +from skillspector.javascript_tokens import assertion_template_shell_text from skillspector.logging_config import get_logger from skillspector.models import AnalyzerFinding, Finding, Location, Severity from skillspector.python_ast import ParsedPythonFile, parse_python_source @@ -164,7 +165,7 @@ _SHELL_ASSIGNMENT_PREFIX_WORDS = _SHELL_CLAUSE_PREFIX_WORDS | frozenset( {"if", "while", "until", "export", "local", "declare", "readonly", "typeset"} ) -_SHELL_REDIRECTION_PREFIX_RE = re.compile(r"(?:[0-9]+)?(?:&>>?|<>|>>?|<<-?|>&|<&|[<>])") +_SHELL_REDIRECTION_PREFIX_RE = re.compile(r"(?:[0-9]+)?(?:&>>?|<>|>&|<&|>>?|<<-?|[<>])") _RECURSIVE_OPTION_SOURCE_RE = re.compile(r"-(?:[A-Za-z]*[rR]|-recursive)") _FORCE_OPTION_SOURCE_RE = re.compile(r"-(?:[A-Za-z]*f|-force)") _MARKDOWN_CONTRACTION_PROSE_RE = re.compile( @@ -2415,8 +2416,10 @@ wrapper_seen = True while True: option, limited = next_word() - if limited or option is None: + if limited: return True, None + if option is None: + return not _ends_wrapped_clause(content, cursor), None if option == "--": pending, limited = next_word() if limited: @@ -2425,6 +2428,9 @@ if not option.startswith("-"): pending = option break + # Unlike nice/xargs, sudo's -n is a flag, not an operand option. + if command == "sudo" and option == "-n": + continue if option in { "-u", "-g", @@ -2448,8 +2454,10 @@ wrapper_seen = True while True: option, limited = next_word() - if limited or option is None: + if limited: return True, None + if option is None: + return not _ends_wrapped_clause(content, cursor), None if option in {"-k", "--kill-after", "-s", "--signal"}: _, limited = next_word() if limited: @@ -2466,6 +2474,24 @@ return wrapper_seen, None +def _ends_wrapped_clause(content: str, cursor: int) -> bool: + """Return whether a wrapper's missing command is a proven end of its clause. + + When a control operator follows ``sudo``, ``nice``, ``xargs`` or + ``timeout`` directly, the clause names no wrapped command, so there is no + command string to reconstruct. A shell such a wrapper starts on its own + (``sudo -s``) reads its input like ``sh`` in a pipeline, which this check + does not model either. Commands after the operator start their own + clause and are checked separately. A redirection may still precede the + wrapped command, and a fragment may continue past its end, so both + remain unresolved. + """ + if cursor >= len(content): + return False + character = content[cursor] + return character in ";|)" or (character == "&" and content[cursor + 1 : cursor + 2] != ">") + + def _command_wrapper_quote(content: str, command_start: int) -> str | None: if command_start == 0 or content[command_start - 1] not in "'\"`": return None @@ -5247,10 +5273,18 @@ complete_context=complete_context, word_apostrophes=word_apostrophes, ) + # Diagnostic template delimiters belong to JavaScript only when the whole + # source proves their assertion-message owner. Destructive checks stay raw. + # TypeScript's postfix/type expressions need a separate lexical proof. + shell_word_content = ( + assertion_template_shell_text(content, check_runtime) + if file_type == "javascript" and complete_context + else content + ) # Perl ``eval BLOCK`` is recognized from its clause-initial ``eval`` and the # adjacent brace alone, so unlike quote ownership it needs no full context. if _has_shell_command_word_exhaustion( - content, + shell_word_content, check_runtime, structural_quote_closers=structural_quote_closers, structural_quote_openers=structural_quote_openers, diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_runner.py new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_runner.py --- old/skillspector-2.13.1/src/skillspector/nodes/analyzers/static_runner.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/src/skillspector/nodes/analyzers/static_runner.py 2020-02-02 01:00:00.000000000 +0100 @@ -110,6 +110,8 @@ ".toml": "toml", ".txt": "text", ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", ".ts": "typescript", ".rb": "ruby", ".pl": "perl", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/src/skillspector/nodes/build_context.py new/skillspector-2.13.2/src/skillspector/nodes/build_context.py --- old/skillspector-2.13.1/src/skillspector/nodes/build_context.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/src/skillspector/nodes/build_context.py 2020-02-02 01:00:00.000000000 +0100 @@ -157,6 +157,8 @@ ".toml": "toml", ".txt": "text", ".js": "javascript", + ".mjs": "javascript", + ".cjs": "javascript", ".ts": "typescript", ".rb": "ruby", ".go": "go", diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/tests/nodes/analyzers/test_command_wrapper_without_command.py new/skillspector-2.13.2/tests/nodes/analyzers/test_command_wrapper_without_command.py --- old/skillspector-2.13.1/tests/nodes/analyzers/test_command_wrapper_without_command.py 1970-01-01 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/tests/nodes/analyzers/test_command_wrapper_without_command.py 2020-02-02 01:00:00.000000000 +0100 @@ -0,0 +1,228 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""A command wrapper followed directly by a control operator runs no command (#694).""" + +from __future__ import annotations + +import pytest + +from skillspector.inspection_ledger import LedgerOutcome, LedgerReason +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 + + +def _exhausted(content: str, file_type: str, *, complete_context: bool = True) -> bool: + return tm_module.has_bounded_parse_exhaustion( + content, lambda: None, file_type=file_type, complete_context=complete_context + ) + + [email protected]("wrapper", ["timeout", "sudo", "nice", "xargs"]) +def test_markdown_table_cell_naming_a_wrapper_is_complete(wrapper: str) -> None: + content = f"| Name | Description |\n|---|---|\n| {wrapper} | Max wait in seconds |\n" + + assert not _exhausted(content, "markdown") + assert not _exhausted(content + _PADDING, "markdown") + + +def test_markdown_api_parameter_table_is_complete() -> None: + content = ( + "| Name | Type | Default | Description |\n" + "|---|---|---|---|\n" + "| timeout | float | 150 | Seconds to wait |\n" + "| retries | int | 3 | Attempts |\n" + ) + + assert not _exhausted(content, "markdown") + + [email protected]( + ("content", "file_type"), + [ + ("sudo | cat\n", "shell"), + ("timeout; echo ok\n", "shell"), + ("xargs || true\n", "shell"), + ("nice & wait\n", "shell"), + ("(nice)\n", "shell"), + ("sudo -u | cat\n", "shell"), + ("sudo |& cat\n", "shell"), + ("timeout && echo ok\n", "shell"), + ("case $a in x) nice ;; esac\n", "shell"), + ("signal.alarm(timeout)\nvalue = 1\n", "python"), + ("if (timeout) { start(); }\n", "javascript"), + ], + ids=[ + "pipe", + "semicolon", + "or-list", + "background", + "subshell", + "option-without-value", + "pipe-with-stderr", + "and-list", + "case-item-end", + "python-call", + "javascript-condition", + ], +) +def test_wrapper_before_a_clause_end_is_complete(content: str, file_type: str) -> None: + assert not _exhausted(content + _PADDING, file_type) + + [email protected]( + "content", + [ + "sudo >log $CMD -rf /\n", + "sudo <in $CMD -rf /\n", + "sudo &>log $CMD -rf /\n", + "timeout ($CMD) -rf /\n", + 'x | sudo sh -c "$CMD"\n', + 'sudo |& sh -c "$CMD"\n', + 'sudo && eval "$CMD"\n', + "case $a in sudo) $CMD -rf / ;; esac\n", + 'sudo -s; eval "$CMD"\n', + ], + ids=[ + "stdout-redirection", + "stdin-redirection", + "bash-and-redirection", + "parenthesis", + "command-string", + "command-string-after-pipe", + "eval-after-and-list", + "runtime-command-in-case-item", + "eval-after-shell-option", + ], +) +def test_wrapper_with_an_unresolved_command_stays_partial(content: str) -> None: + assert _exhausted(content, "shell") + + +def test_wrapper_at_the_end_of_a_fragment_stays_partial() -> None: + assert _exhausted("x | sudo", "shell") + assert _exhausted("x | timeout", "shell", complete_context=False) + + +def test_markdown_cell_with_a_real_command_keeps_its_finding() -> None: + literal = "| Step | Command |\n|---|---|\n| wipe | `sudo rm -rf /` |\n" + runtime = "| Step | Command |\n|---|---|\n| wipe | `sudo $CMD -rf /` |\n" + + findings = tm_module.analyze(literal, "SKILL.md", "markdown") + + assert any(finding.rule_id == "TM1" for finding in findings) + assert _exhausted(runtime, "markdown") + + +def test_scan_level_parameter_table_is_fully_inspected() -> None: + content = "# Tool\n\n| Name | Type |\n|---|---|\n| timeout | float |\n" + "\nText.\n" * 400 + + result = static_runner.run_static_patterns_with_ledger( + {"components": ["SKILL.md"], "file_cache": {"SKILL.md": content}}, [tm_module] + ) + + event = result["inspection_ledger"][0] + assert event["outcome"] is LedgerOutcome.COMPLETED + + +def test_scan_level_redirected_wrapper_stays_partial() -> None: + result = static_runner.run_static_patterns_with_ledger( + {"components": ["SKILL.md"], "file_cache": {"SKILL.md": "sudo >log $CMD -rf /\n"}}, + [tm_module], + ) + + event = result["inspection_ledger"][0] + assert event["outcome"] is LedgerOutcome.PARTIAL + assert event["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT + + [email protected]("wrapper", ["timeout", "sudo", "nice", "xargs"]) [email protected]("quote", ["'", '"', '"""']) [email protected]("newline", ["\n", "\r\n"]) +def test_python_parenthesized_wrapper_name_is_complete( + wrapper: str, quote: str, newline: str +) -> None: + content = ( + '"""Describe a fixture beside its manifest."""\n' + f"WRAPPER_NAME = (\n {quote}{wrapper}{quote}\n)\n" + f"def read_option(options):\n return options.get({quote}{wrapper}{quote})\n" + ).replace("\n", newline) + + assert not _exhausted(content, "python") + result = static_runner.run_static_patterns_with_ledger( + {"components": ["scripts/validate.py"], "file_cache": {"scripts/validate.py": content}}, + [tm_module], + ) + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + + [email protected]( + "source", + [ + 'sudo -n fuser "$device" >/dev/null 2>&1 && continue\n', + "sudo -n true >/dev/null 2>&1\n", + "sudo -n true 2>&1 >/dev/null\n", + "sudo -n true 0<&3 3>&-\n", + "sudo -n true 3>&1-\n", + "sudo -n true &>/dev/null\n", + "sudo -n true &>>/dev/null\n", + "sudo -n -- true 2>&1\n", + "sudo -n -u root true 2>&1\n", + "sudo -u root -n true 2>&1\n", + "sudo --non-interactive true 2>&1\n", + "sudo -nS true 2>&1\n", + "nice -n 5 true 2>&1\n", + "xargs -n 1 true 2>&1\n", + "sudo -n sh 2>&1 -c 'echo ready'\n", + "eval 2>&1 'echo ready'\n", + ], +) +def test_noninteractive_sudo_and_descriptor_redirections_are_complete(source: str) -> None: + assert not _exhausted(source, "shell") + + [email protected]( + "source", + [ + 'sudo -n sh -c "$CMD"\n', + 'sudo -n sh 2>&1 -c "$CMD"\n', + 'sudo -n sh >log -c "$CMD"\n', + 'sudo -n -- sh -c "$CMD"\n', + 'sudo -n -u root sh -c "$CMD"\n', + 'nice -n 5 sh -c "$CMD"\n', + 'xargs -n 1 sh -c "$CMD"\n', + "sudo -n sh -c '$CMD -rf /'\n", + 'eval 2>&1 "$CMD"\n', + "sudo -n $CMD -rf /\n", + "sudo -n >log $CMD -rf /\n", + "sudo -n &>log $CMD -rf /\n", + "sudo -n true 2>&\n", + "sudo -n true 0<&\n", + "sudo -n true 2>\n", + "sudo -n true >\n", + "sudo -n", + "nice -n", + "xargs -n", + "timeout", + 'sudo -n sh -c "unterminated', + ], +) +def test_wrapper_repairs_preserve_unresolved_command_limits(source: str) -> None: + assert _exhausted(source, "shell") + + +def test_noninteractive_sudo_keeps_destructive_finding() -> None: + source = "sudo -n rm -rf / 2>&1\n" + + findings = tm_module.analyze(source, "scripts/run.sh", "shell") + + assert any(finding.rule_id == "TM1" for finding in findings) + + [email protected]("wrapper", ["sudo -n", "nice -n 1", "xargs -n 1"]) +def test_wrapper_chain_budget_stays_partial(wrapper: str) -> None: + source = " ".join([wrapper] * 40) + " sh -c 'echo ready'\n" + + assert _exhausted(source, "shell") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/tests/nodes/analyzers/test_host_language_shell_ownership.py new/skillspector-2.13.2/tests/nodes/analyzers/test_host_language_shell_ownership.py --- old/skillspector-2.13.1/tests/nodes/analyzers/test_host_language_shell_ownership.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/tests/nodes/analyzers/test_host_language_shell_ownership.py 2020-02-02 01:00:00.000000000 +0100 @@ -1,12 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Valid Python and Perl syntax must not exhaust the bounded shell parser. +"""Proven host-language syntax must not exhaust the bounded shell parser. Python string and comment tokens own their bytes, and Perl ``eval BLOCK`` is exception handling rather than a string reparse. Neither may turn ordinary host source into a ``static_parse_limit`` result, while genuinely unresolved -shell text keeps its conservative partial outcome. +shell text keeps its conservative partial outcome. Verified Node assertion +messages may own template delimiters without hiding executable string contents. """ from __future__ import annotations @@ -451,6 +452,423 @@ assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED +# --- Node assertion diagnostic templates ---------------------------------- + + +_JS_ASSERT_IMPORT = 'import check from "node:assert/strict";\n' +_JS_ASSERT_CALL = 'check.equal(typeof row.name, "string", `${row.key} must be present`);\n' + + +def _javascript(content: str, *, complete_context: bool = True) -> bool: + return tm.has_bounded_parse_exhaustion( + content, lambda: None, file_type="javascript", complete_context=complete_context + ) + + [email protected]( + "declaration", + [ + 'import check from "node:assert";', + 'import check from "node:assert/strict";', + "import * as check from 'node:assert';", + "import * as check from 'node:assert/strict';", + ], +) +def test_imported_node_assert_message_template_is_complete(declaration: str) -> None: + source = declaration + "\n" + _JS_ASSERT_CALL + + assert _javascript(source) is False + assert _shell(source) is True + + [email protected]( + "method,arguments", + [ + ("ok", "valid"), + ("equal", "actual, expected"), + ("notEqual", "actual, expected"), + ("strictEqual", "actual, expected"), + ("notStrictEqual", "actual, expected"), + ("deepEqual", "actual, expected"), + ("notDeepEqual", "actual, expected"), + ("deepStrictEqual", "actual, expected"), + ("notDeepStrictEqual", "actual, expected"), + ("match", "actual, /value/"), + ("doesNotMatch", "actual, /value/"), + ("throws", "() => work(), undefined"), + ("doesNotThrow", "() => work(), undefined"), + ], +) +def test_node_assert_owns_only_its_message_argument(method: str, arguments: str) -> None: + source = _JS_ASSERT_IMPORT + f"check.{method}({arguments}, `${{row.key}} needs a value`);\n" + + assert _javascript(source) is False + + [email protected]("suffix", [".js", ".mjs", ".cjs", ".MJS"]) +def test_node_assert_message_ownership_reaches_static_ledger(suffix: str) -> None: + result = _ledger("scripts/check" + suffix, _JS_ASSERT_IMPORT + _JS_ASSERT_CALL) + + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + assert not any(finding.rule_id == "TM1" for finding in result["findings"]) + + [email protected]("newline", ["\n", "\r\n"], ids=["lf", "crlf"]) +def test_node_assert_template_offsets_preserve_dangerous_neighbor(newline: str) -> None: + source = ( + _JS_ASSERT_IMPORT + + "check.ok(ready, `café ${row.key} is ready`);\n" + + 'execSync("rm -rf /");\n' + ).replace("\n", newline) + + result = _ledger("scripts/check.mjs", source) + + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.COMPLETED + assert any( + finding.rule_id == "TM1" and finding.start_line == 3 for finding in result["findings"] + ) + + [email protected]( + "source", + [ + pytest.param(_JS_ASSERT_CALL, id="unbound-name"), + pytest.param( + 'import check from "custom-assert";\n' + _JS_ASSERT_CALL, id="non-node-import" + ), + pytest.param( + 'import { strict as check } from "node:assert";\n' + _JS_ASSERT_CALL, + id="unsupported-named-import", + ), + pytest.param(_JS_ASSERT_IMPORT + "check = run;\n" + _JS_ASSERT_CALL, id="rebound"), + pytest.param( + _JS_ASSERT_IMPORT + "function verify(check) {\n" + _JS_ASSERT_CALL + "}\n", + id="shadowed-parameter", + ), + pytest.param( + _JS_ASSERT_IMPORT + "const alias = check;\n" + _JS_ASSERT_CALL, id="escaped-binding" + ), + pytest.param( + _JS_ASSERT_IMPORT + "const run = check.equal;\n" + _JS_ASSERT_CALL, + id="aliased-method", + ), + pytest.param( + _JS_ASSERT_IMPORT + "const message = `${check}`;\n" + _JS_ASSERT_CALL, + id="binding-interpolated-elsewhere", + ), + pytest.param( + _JS_ASSERT_IMPORT + "const message = `${check.equal}`;\n" + _JS_ASSERT_CALL, + id="method-interpolated-elsewhere", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal = run;\n" + _JS_ASSERT_CALL, + id="method-replacement", + ), + pytest.param( + _JS_ASSERT_IMPORT + 'check["equal"] = run;\n' + _JS_ASSERT_CALL, + id="computed-replacement", + ), + pytest.param( + _JS_ASSERT_IMPORT + "Object.assign(check, { equal: run });\n" + _JS_ASSERT_CALL, + id="object-mutation", + ), + pytest.param( + _JS_ASSERT_IMPORT + + 'import alternate from "node:assert";\nalternate.equal = run;\n' + + _JS_ASSERT_CALL, + id="second-import-alias", + ), + pytest.param( + _JS_ASSERT_IMPORT + 'require("node:assert/strict").equal = run;\n' + _JS_ASSERT_CALL, + id="require-alias", + ), + pytest.param( + _JS_ASSERT_IMPORT + + r'import alternate from "node:\u0061ssert";' + + "\nalternate.equal = run;\n" + + _JS_ASSERT_CALL, + id="escaped-module-import", + ), + pytest.param( + _JS_ASSERT_IMPORT + + r'require("node:\u0061ssert").equal = run;' + + "\n" + + _JS_ASSERT_CALL, + id="escaped-module-require", + ), + pytest.param( + _JS_ASSERT_IMPORT + + "const of = 1; const n = of / (check.equal = run) / divisor;\n" + + _JS_ASSERT_CALL, + id="division-after-contextual-keyword", + ), + pytest.param( + _JS_ASSERT_IMPORT + + "const n = holder.return / (check.equal = run) / divisor;\n" + + _JS_ASSERT_CALL, + id="division-after-keyword-property", + ), + pytest.param( + _JS_ASSERT_IMPORT + 'check["equal"](a, b, `${row.key} is required`);\n', + id="computed-call", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check?.equal(a, b, `${row.key} is required`);\n", + id="optional-call", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check(a, `${row.key} is required`);\n", + id="unsupported-callable-assert", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal(`${row.key}`, b);\n", + id="template-is-actual-value", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal(a, b, `${row.key}`, extra);\n", + id="extra-argument", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal(...values, `${row.key}`);\n", + id="spread-argument", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal(a, b, tag`${row.key}`);\n", + id="tagged-message", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal(a, b, (`${row.key}`));\n", + id="parenthesized-message", + ), + pytest.param( + _JS_ASSERT_IMPORT + 'check.equal(a, b, `${row.key}` + " more");\n', + id="concatenated-message", + ), + pytest.param( + _JS_ASSERT_IMPORT + "check.equal(a, b, message = `${row.key}`);\n", + id="assigned-message", + ), + pytest.param( + _JS_ASSERT_IMPORT + "const message = `${row.key}`;\n", + id="unrelated-assignment", + ), + pytest.param( + _JS_ASSERT_IMPORT + "function message() { return `${row.key}`; }\n", + id="unrelated-return", + ), + pytest.param("#!/bin/sh\n" + _JS_ASSERT_IMPORT + _JS_ASSERT_CALL, id="shell-shebang"), + pytest.param(_JS_ASSERT_IMPORT + "check.equal(a, b, `${row.key}\n", id="unclosed-template"), + pytest.param(_JS_ASSERT_IMPORT + "check.equal(a, b, `${row.key`);\n", id="unclosed-field"), + pytest.param(_JS_ASSERT_IMPORT + _JS_ASSERT_CALL + "/* unfinished", id="unclosed-comment"), + pytest.param( + _JS_ASSERT_IMPORT + "const pattern = /unterminated\n" + _JS_ASSERT_CALL, + id="unclosed-regexp", + ), + pytest.param( + _JS_ASSERT_IMPORT + "const pattern = /value/gg;\n" + _JS_ASSERT_CALL, + id="invalid-regexp-flags", + ), + pytest.param( + _JS_ASSERT_IMPORT + "const pattern = /value\\\nmore/;\n" + _JS_ASSERT_CALL, + id="escaped-newline-in-regexp", + ), + ], +) +def test_unproven_node_assert_binding_or_message_is_not_projected(source: str) -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + assert assertion_template_shell_text(source, lambda: None) == source + assert _javascript(source) is _shell(source) + + [email protected]( + "operand", + [ + "value!", + "value !", + "value /* gap */ !", + "value! /* gap */", + "holder.value!", + "getValue()!", + "(value)!", + "values[0]!", + ], +) [email protected]("file_type", ["javascript", "typescript"]) +def test_postfix_non_null_assertion_cannot_hide_receiver_mutation( + operand: str, file_type: str +) -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + source = ( + _JS_ASSERT_IMPORT + + f"const n = {operand} / (check.equal = run) / divisor;\n" + + _JS_ASSERT_CALL + ) + + assert assertion_template_shell_text(source, lambda: None) == source + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type=file_type) is True + + [email protected]( + "expression", + ["!value", "!!value", "!/value/.test(input)", "value != other", "value !== other"], +) +def test_prefix_negation_and_inequality_keep_assert_message_ownership(expression: str) -> None: + source = _JS_ASSERT_IMPORT + f"const ready = {expression};\n" + _JS_ASSERT_CALL + + assert _javascript(source) is False + assert _shell(source) is True + + [email protected]( + "statement", + [ + "", + "const n = value! / (check.equal = run) / divisor;\n", + "const n = factory<number> / (check.equal = run) / divisor;\n", + ], +) +def test_typescript_does_not_borrow_javascript_assert_ownership(statement: str) -> None: + source = _JS_ASSERT_IMPORT + statement + _JS_ASSERT_CALL + + assert tm.has_bounded_parse_exhaustion(source, lambda: None, file_type="typescript") is True + result = _ledger("scripts/check.ts", source) + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert result["inspection_ledger"][0]["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT + + [email protected]( + "prefix,message", + [ + ("// check.equal = run;\n", "ready ${row.key}"), + ("/* check is a diagnostic receiver */\n", "ready ${row.key}"), + (r"""const pattern = /['"]/;""" + "\n", "ready ${row.key}"), + ("", r"escaped \` delimiter ${row.key}"), + ("", "multiple ${label} and ${row.key} values"), + ], +) +def test_node_assert_projection_changes_only_proven_delimiters(prefix: str, message: str) -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + source = _JS_ASSERT_IMPORT + prefix + f"check.equal(a, b, `{message}`);\n" + opening = source.index("`", len(_JS_ASSERT_IMPORT + prefix)) + closing = source.rindex("`") + + projected = assertion_template_shell_text(source, lambda: None) + + assert len(projected) == len(source) + assert [ + index + for index, pair in enumerate(zip(source, projected, strict=True)) + if pair[0] != pair[1] + ] == [ + opening, + closing, + ] + assert projected[opening] == projected[closing] == " " + + [email protected]( + "expression", + [ + "execSync(obj.cmd)", + "new Command(obj.cmd)", + "(target = obj.cmd)", + "counter++", + "++counter", + "obj[key]", + "obj?.cmd", + "`nested ${obj.cmd}`", + ], +) +def test_effectful_or_unproven_assert_interpolation_is_not_projected(expression: str) -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + source = _JS_ASSERT_IMPORT + f"check.equal(a, b, `${{{expression}}} ready`);\n" + + assert assertion_template_shell_text(source, lambda: None) == source + assert _javascript(source) is _shell(source) + + [email protected]( + "statement", + [ + "execSync(`${obj.cmd}`);", + "run(`${obj.cmd}`);", + "execSync(`${getCmd()}`);", + "execSync(`${cmd} -rf /`);", + "execSync(`${obj.cmd} -rf /`);", + 'execSync(`${obj.cmd}` + " -rf /");', + ], +) +def test_node_assert_message_fix_keeps_unresolved_shell_commands_partial(statement: str) -> None: + source = _JS_ASSERT_IMPORT + _JS_ASSERT_CALL + statement + "\n" + + assert _javascript(source) is True + result = _ledger("scripts/check.mjs", source) + assert result["inspection_ledger"][0]["outcome"] is LedgerOutcome.PARTIAL + assert result["inspection_ledger"][0]["reason_code"] is LedgerReason.STATIC_PARSE_LIMIT + + [email protected]( + "statement", + [ + "run(`${cmd}`);", + "sh`${obj.cmd}`;", + "sh`${obj.cmd} -rf /`;", + "execSync(String.raw`${obj.cmd}`);", + 'execSync(`${cmd}` + " -rf /");', + ], +) +def test_unrecognized_template_consumers_keep_existing_behavior(statement: str) -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + source = _JS_ASSERT_IMPORT + statement + "\n" + + assert assertion_template_shell_text(source, lambda: None) == source + assert _javascript(source) is _shell(source) + + [email protected]( + "statement", + [ + "execSync(`rm -rf /`);", + "check.equal(a, b, `${execSync('rm -rf /')} ready`);", + "check.equal(a, b, `rm -rf / ${row.key}`);", + ], +) +def test_assert_template_ownership_preserves_destructive_text(statement: str) -> None: + result = _ledger("scripts/check.mjs", _JS_ASSERT_IMPORT + statement + "\n") + + assert any(finding.rule_id == "TM1" for finding in result["findings"]) + + +def test_node_assert_message_ownership_requires_complete_javascript_context() -> None: + source = _JS_ASSERT_IMPORT + _JS_ASSERT_CALL + + assert _javascript(source, complete_context=False) is True + assert _shell(source) is True + + +def test_node_assert_template_scan_propagates_runtime_interruptions() -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + def check_runtime() -> None: + raise TimeoutError("test runtime bound") + + with pytest.raises(TimeoutError, match="test runtime bound"): + assertion_template_shell_text(_JS_ASSERT_IMPORT + _JS_ASSERT_CALL * 1000, check_runtime) + + +def test_oversized_javascript_does_not_gain_template_ownership() -> None: + from skillspector.javascript_tokens import assertion_template_shell_text + + source = _JS_ASSERT_IMPORT + "/*" + "x" * 1_100_000 + "*/\n" + _JS_ASSERT_CALL + + assert assertion_template_shell_text(source, lambda: None) == source + + # --- Whole-scan accounting ------------------------------------------------- diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/tests/nodes/test_nested_artifacts.py new/skillspector-2.13.2/tests/nodes/test_nested_artifacts.py --- old/skillspector-2.13.1/tests/nodes/test_nested_artifacts.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/tests/nodes/test_nested_artifacts.py 2020-02-02 01:00:00.000000000 +0100 @@ -1030,6 +1030,26 @@ assert parsed.is_parseable [email protected]("suffix", ["mjs", "cjs"]) +def test_javascript_module_types_and_findings_survive_archive_inventory( + tmp_path: Path, suffix: str +) -> None: + (tmp_path / "SKILL.md").write_text("# JavaScript modules\n", encoding="utf-8") + content = b'const command = "rm -rf /";\n' + filename = f"helper.{suffix}" + (tmp_path / filename).write_bytes(content) + _write_archive(tmp_path / "bundle.zip", {filename: content}) + + context = build_context({"skill_path": str(tmp_path)}) + paths = {filename, f"bundle.zip!/{filename}"} + metadata = {item["path"]: item for item in context["component_metadata"]} + + assert all(metadata[path]["type"] == "javascript" for path in paths) + assert all(metadata[path]["executable"] for path in paths) + findings = analyze_tool_misuse(context)["findings"] + assert paths <= {finding.file for finding in findings if finding.rule_id == "TM1"} + + def test_nested_relative_selected_script_remains_partial(tmp_path: Path) -> None: """Archive provenance cannot establish the invocation working directory.""" (tmp_path / "SKILL.md").write_text("# Nested Python\n", encoding="utf-8") diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/tests/test_mcp_tool_poisoning.py new/skillspector-2.13.2/tests/test_mcp_tool_poisoning.py --- old/skillspector-2.13.1/tests/test_mcp_tool_poisoning.py 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/tests/test_mcp_tool_poisoning.py 2020-02-02 01:00:00.000000000 +0100 @@ -1956,6 +1956,140 @@ class TestResourceBounds: + @pytest.mark.parametrize( + ("fence_count", "expected_calls", "is_partial"), + [(64, 64, False), (65, 65, False), (128, 128, False), (129, 128, True)], + ) + def test_tp4_batch_boundaries_preserve_late_findings_and_source_ranges( + self, + monkeypatch: pytest.MonkeyPatch, + fence_count: int, + expected_calls: int, + is_partial: bool, + ) -> None: + content = "".join(f"```python\nprint({index})\n```\n" for index in range(fence_count)) + responses = [{"is_mismatch": False} for _ in range(expected_calls - 1)] + responses.append( + { + "is_mismatch": True, + "confidence": 0.9, + "mismatched_capabilities": ["network access"], + } + ) + structured = _mock_tp4_structured_llm(monkeypatch, responses) + + result = node( + { + "manifest": {"name": "bounded", "description": "Runs local calculations."}, + "file_cache": {"guide.md": content}, + "component_metadata": [{"path": "guide.md", "type": "markdown"}], + "use_llm": True, + "model_config": {"default": "test-model"}, + } + ) + + assert structured.calls == expected_calls + semantic = [event for event in result["inspection_ledger"] if event["phase"] == "semantic"] + completed = [event for event in semantic if event["outcome"] is LedgerOutcome.COMPLETED] + assert [(event["path"], event["start_line"], event["end_line"]) for event in completed] == [ + ("guide.md", index * 3 + 2, index * 3 + 2) for index in range(expected_calls) + ] + finding = result["findings"][0] + assert len(result["findings"]) == 1 + assert finding.evidence["code_path"] == "guide.md" + assert finding.evidence["code_start_line"] == (expected_calls - 1) * 3 + 2 + assert finding.evidence["code_end_line"] == (expected_calls - 1) * 3 + 2 + assert completed[-1]["emitted_finding_ids"] == [finding.finding_id] + partial = [event for event in semantic if event["outcome"] is LedgerOutcome.PARTIAL] + if is_partial: + assert len(partial) == 1 + assert partial[0]["path"] == "guide.md" + assert partial[0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + assert partial[0]["observed_records"] == 129 + assert partial[0]["limit_records"] == 128 + assert result["analyzer_status_events"][0]["status"] == "degraded" + else: + assert partial == [] + assert result["analyzer_status_events"][0]["status"] == "completed" + + @pytest.mark.parametrize("tail", ["oversized", "provider_error", "deadline"]) + def test_tp4_late_incomplete_work_remains_visible( + self, monkeypatch: pytest.MonkeyPatch, tail: str + ) -> None: + content = "```python\nprint('local')\n```\n" * 64 + responses: list[object] = [{"is_mismatch": False} for _ in range(64)] + if tail == "oversized": + content += "```python\n" + "x" * 128_000 + "\n```\n" + expected_outcome = LedgerOutcome.PARTIAL + expected_reason = LedgerReason.SIZE_LIMIT + else: + content += "```python\nprint('last')\n```\n" + if tail == "provider_error": + responses.append(RuntimeError("provider failure")) + expected_outcome = LedgerOutcome.FAILED + expected_reason = LedgerReason.LLM_BATCH_FAILED + else: + responses.append(mcp_tool_poisoning.LLMRuntimeLimitError("deadline")) + expected_outcome = LedgerOutcome.PARTIAL + expected_reason = LedgerReason.RUNTIME_LIMIT + structured = _mock_tp4_structured_llm(monkeypatch, responses) + + result = node( + { + "manifest": {"name": "bounded", "description": "Runs local calculations."}, + "file_cache": {"guide.md": content}, + "component_metadata": [{"path": "guide.md", "type": "markdown"}], + "use_llm": True, + "model_config": {"default": "test-model"}, + } + ) + + assert structured.calls == (64 if tail == "oversized" else 65) + semantic = [event for event in result["inspection_ledger"] if event["phase"] == "semantic"] + assert sum(event["outcome"] is LedgerOutcome.COMPLETED for event in semantic) == 64 + incomplete = [ + event for event in semantic if event["outcome"] is not LedgerOutcome.COMPLETED + ] + assert len(incomplete) == 1 + assert incomplete[0]["outcome"] is expected_outcome + assert incomplete[0]["reason_code"] is expected_reason + assert incomplete[0]["path"] == "guide.md" + assert incomplete[0]["start_line"] == incomplete[0]["end_line"] == 194 + assert result["analyzer_status_events"][0]["status"] == ( + "failed" if tail == "provider_error" else "degraded" + ) + + def test_tp4_expanded_batch_allowance_keeps_finding_output_cap(self, monkeypatch): + structured = _mock_tp4_structured_llm( + monkeypatch, + [ + { + "is_mismatch": True, + "confidence": 0.9, + "mismatched_capabilities": [f"undeclared capability {index}"], + } + for index in range(65) + ], + ) + result = mcp_tool_poisoning._check_tp4( + { + "manifest": {"description": "Runs local calculations."}, + "file_cache": {"guide.md": "```python\nprint('local')\n```\n" * 65}, + "component_metadata": [{"path": "guide.md", "type": "markdown"}], + "model_config": {"default": "test-model"}, + } + ) + + assert structured.calls == 65 + assert len(result.findings) == 64 + partial = [event for event in result.ledger if event["outcome"] is LedgerOutcome.PARTIAL] + assert len(partial) == 1 + assert partial[0]["reason_code"] is LedgerReason.OUTPUT_LIMIT + assert partial[0]["observed_findings"] == 65 + assert partial[0]["limit_findings"] == 64 + assert partial[0]["path"] == "guide.md" + assert partial[0]["start_line"] == partial[0]["end_line"] == 194 + def test_static_finding_cap_is_enforced_during_detector_construction( self, monkeypatch: pytest.MonkeyPatch ) -> None: diff -urN '--exclude=CVS' '--exclude=.cvsignore' '--exclude=.svn' '--exclude=.svnignore' old/skillspector-2.13.1/uv.lock new/skillspector-2.13.2/uv.lock --- old/skillspector-2.13.1/uv.lock 2020-02-02 01:00:00.000000000 +0100 +++ new/skillspector-2.13.2/uv.lock 2020-02-02 01:00:00.000000000 +0100 @@ -2687,7 +2687,7 @@ [[package]] name = "skillspector" -version = "2.13.1" +version = "2.13.2" source = { editable = "." } dependencies = [ { name = "boto3" },
