diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index 60423a87e..639170e22 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -44,9 +44,28 @@ ANALYZER_ID = "static_patterns_excessive_agency" # EA1: Unrestricted Tool Access -# Configuration syntax can span blank lines; prose instructions cannot. EA1_CODE_PATTERNS = [ - (r"(?:tools?|permissions?)\s*:[ \t]*\[?[ \t]*['\"]?\*(?!\*|\w)['\"]?[ \t]*\]?", 0.85), + # Consume complete scalar elements so quoted tool arguments cannot look + # like wildcard grants. Possessive token bodies avoid repartitioning long + # near-misses; unquoted nested arrays/objects remain outside this grammar. + ( + r"['\"]?(?:tools?|permissions?)['\"]?[ \t]*:[ \t]*" + r"(?:\[(?:[ \t]*+(?:\"(?:[^\"\\\r\n]++|\\[^\r\n])*+\"" + r"|'(?:[^'\r\n]++|'')*+'|[^\][{},'\"\r\n]++)[ \t]*+,)*" + r"[ \t]*(?:\"\*\"|'\*')(?=[ \t]*[,\]])" + r"|(?:\"\*\"|'\*')(?=[ \t]*(?:[,}#]|\r?$))" + r"|\[?[ \t]*\*(?![\*\w])[ \t]*\]?[ \t]*(?:#[^\r\n]*)?\r?$)", + 0.85, + ), + # YAML block list whose first item is the wildcard (#445). Bounded to a + # single newline — a blank line still breaks the match — and the item's + # '*' must be standalone, so markdown lists ("- **Read**", "- *note*") + # do not collide. Later items are out of scope until seen in practice. + ( + r"['\"]?(?:tools?|permissions?)['\"]?[ \t]*:[ \t]*(?:#[^\r\n]*)?\r?\n" + r"[ \t]*-[ \t]+['\"]?\*(?![\*\w])['\"]?[ \t]*(?:#[^\r\n]*)?\r?$", + 0.85, + ), (r"tools?\s*:\s*\[\s*['\"]shell['\"].*?['\"](?:file_write|network|http)['\"]", 0.7), ] EA1_PROSE_PATTERNS = [ diff --git a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py index 876879fc4..82af78179 100644 --- a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py +++ b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py @@ -28,6 +28,8 @@ from __future__ import annotations +import pytest + from skillspector.nodes.analyzers import ( static_patterns_excessive_agency as ea_module, ) @@ -114,3 +116,209 @@ def test_unquoted_wildcard_flagged(self) -> None: "markdown", ) assert any(f.rule_id == "EA1" for f in findings) + + +class TestEA1FootnoteAndPreColonNotFlagged: + """Remaining false-positive paths from #444: a footnote legend after a + 'Tools:' label, and a blank-line gap before the colon.""" + + def test_footnote_legend_after_tools_label_not_flagged(self) -> None: + findings = ea_module.analyze( + "Tools: * = requires authentication\n", + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_footnote_prose_after_tools_label_not_flagged(self) -> None: + findings = ea_module.analyze( + "Tools: * marks optional parameters\n", + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_blank_line_before_definition_list_colon_not_flagged(self) -> None: + """The gap before the colon must stay on one line too — a markdown + definition list two paragraphs later is not a grant.""" + findings = ea_module.analyze( + "several tools\n\n: * item\n", + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_bare_wildcard_with_trailing_comment_still_flagged(self) -> None: + """A '#' comment after the bare wildcard is still a grant, not prose.""" + findings = ea_module.analyze( + "tools: * # allow everything\n", + "SKILL.md", + "markdown", + ) + assert any(f.rule_id == "EA1" for f in findings) + + +class TestEA1BlockListAndJsonFormsFlagged: + """Detection gaps from #445: the idiomatic YAML block-list and JSON + quoted-key encodings of a wildcard grant.""" + + def test_yaml_block_list_quoted_wildcard_flagged(self) -> None: + findings = ea_module.analyze( + 'tools:\n - "*"\n', + "SKILL.md", + "markdown", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_yaml_block_list_bare_wildcard_flagged(self) -> None: + findings = ea_module.analyze( + "tools:\n - *\n", + "SKILL.md", + "markdown", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_yaml_block_list_zero_indent_flagged(self) -> None: + findings = ea_module.analyze( + 'permissions:\n- "*"\n', + "SKILL.md", + "markdown", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_json_quoted_key_list_wildcard_flagged(self) -> None: + findings = ea_module.analyze( + '"tools": ["*"]\n', + "config.json", + "json", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_json_quoted_key_scalar_wildcard_flagged(self) -> None: + findings = ea_module.analyze( + '"permissions": "*"\n', + "config.json", + "json", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_inline_list_wildcard_not_first_flagged(self) -> None: + findings = ea_module.analyze( + 'tools: ["search", "*"]\n', + "SKILL.md", + "markdown", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_markdown_dash_list_of_bold_tools_not_flagged(self) -> None: + """The block-list branch must not collide with a markdown list of + specific bolded tool names.""" + findings = ea_module.analyze( + "Tools:\n- **Read**\n- **Write**\n", + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_blank_line_before_dash_item_not_flagged(self) -> None: + """A blank line between the key and a dash item breaks the block-list + association — the cross-paragraph bridge from #405 must not return.""" + findings = ea_module.analyze( + 'tools:\n\n - "*"\n', + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + +class TestEA1NestedObjectArgumentNotFlagged: + """A '*' nested inside an explicit tool object is an argument value for a + specifically named tool, not a wildcard grant — the wildcard must be a + top-level list element.""" + + def test_json_tool_object_with_wildcard_argument_not_flagged(self) -> None: + findings = ea_module.analyze( + 'tools: [{"name": "grep", "pattern": "*"}]\n', + "config.json", + "json", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_yaml_flow_tool_object_with_wildcard_argument_not_flagged(self) -> None: + findings = ea_module.analyze( + 'tools: [{name: search, glob: "*"}]\n', + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_top_level_wildcard_after_named_element_still_flagged(self) -> None: + """Narrowing to top-level elements must not lose the plain mixed list.""" + findings = ea_module.analyze( + 'tools: ["search", "*"]\n', + "SKILL.md", + "markdown", + ) + assert any(f.rule_id == "EA1" for f in findings) + + def test_nested_array_wildcard_not_flagged(self) -> None: + """A '*' inside a nested array is not a top-level tool-list element.""" + findings = ea_module.analyze( + 'tools: ["search", ["grep", "*"]]\n', + "SKILL.md", + "markdown", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + def test_json_tool_object_with_nested_arguments_not_flagged(self) -> None: + """A '*' buried in a named tool's argument object is not a grant.""" + findings = ea_module.analyze( + '"tools": [{"name": "search", "arguments": {"glob": "*"}}]\n', + "config.json", + "json", + ) + assert not any(f.rule_id == "EA1" for f in findings) + + +@pytest.mark.parametrize( + "value", + [ + "[\"grep --include='*'\"]", + "['grep --include=\"*\"']", + '["search", "grep --include=\'*\'"]', + r'["grep --include=\"*\""]', + "['grep --include=''*''']", + '["*" + "suffix"]', + '["search" "*"]', + '["search", "*name"]', + '["search", "name*"]', + '["search", ["grep", "*"]]', + '[{"name": "search", "arguments": {"glob": "*"}}]', + '["search", "*\']', + '["search", \'*" ]', + '["search", "unterminated \'*\']', + ], +) +def test_only_complete_top_level_wildcard_elements_are_flagged(value: str) -> None: + findings = ea_module.analyze(f"tools: {value}\n", "SKILL.md", "markdown") + assert not any(f.rule_id == "EA1" for f in findings) + + +@pytest.mark.parametrize( + "value", + [ + '["*"]', + '["*", "search"]', + '["search", "*"]', + '[search, "*"]', + '["search {x}", "*"]', + '["search [x]", "*"]', + '["grep --include=\'*\'", "*"]', + r'["grep --include=\"*\"", "*"]', + "['grep --include=''*''', '*']", + '["search", "*", "grep"]', + ], +) +def test_complete_wildcard_elements_after_quoted_scalars_are_flagged(value: str) -> None: + findings = ea_module.analyze(f"tools: {value}\n", "SKILL.md", "markdown") + assert any(f.rule_id == "EA1" for f in findings)