diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index f5a89e2f..e52a2ba6 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -44,7 +44,28 @@ # EA1: Unrestricted Tool Access EA1_PATTERNS = [ - (r"(?:tools?|permissions?)\s*:[ \t]*\[?[ \t]*['\"]?\*(?!\*|\w)['\"]?[ \t]*\]?", 0.85), + # Same-line wildcard grant. The key may be quoted (JSON). A quoted '*' is + # unambiguous anywhere on the line (scalar, first list item, or later list + # item); a bare '*' counts only when it ends the line (optionally closed + # by ']' and/or a '#' comment), so a footnote legend like + # "Tools: * = requires auth" is not a grant. The gap around the colon + # stays on one line so the match can never bridge paragraphs (#405, #444). + ( + r"['\"]?(?:tools?|permissions?)['\"]?[ \t]*:[ \t]*" + r"(?:\[[^\]\r\n]*['\"]\*['\"]" + r"|\[?[ \t]*['\"]\*['\"]" + 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"(?:allow|grant|enable)\s+(?:access\s+to\s+)?(?:all|any|every)\s+tools?", 0.8), ( r"(?:no|without)\s+(?:tool|permission|access|capability)\s+(?:restrictions?|constraints?|limitations?)", diff --git a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py index 876879fc..c09fc0f2 100644 --- a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py +++ b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py @@ -114,3 +114,116 @@ 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)