diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index f5a89e2f..50fddd25 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -44,7 +44,34 @@ # 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 as a scalar or a top-level list element — the list branch + # excludes '{', '}', and '[' so it never descends into a nested object or + # array: in tools: [{name: grep, pattern: "*"}] or + # tools: ["search", ["grep", "*"]] the '*' is not a top-level tool-list + # element and is not a grant. (Ceiling: a quoted element containing one + # of those delimiters before a genuine top-level "*" also stops the scan; + # walking quoted strings needs a parser, not a pattern.) 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..2837d01c 100644 --- a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py +++ b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py @@ -114,3 +114,165 @@ 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)