From a6b1d917fd8c0487a308ae2bfac0352bf42a8155 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Thu, 27 Aug 2026 12:11:02 +0530 Subject: [PATCH 1/2] fix(ea1): reject footnote-legend '*', bound pre-colon gap, detect block-list and JSON wildcard grants Fixes #444. Fixes #445. Follow-up to #405/#417. Remaining false positives (#444): - A bare '*' now counts only when it ends the line (optionally ']' and/or a '#' comment), so footnote legends like 'Tools: * = requires auth' no longer fire. Quoted '*' stays unambiguous as a scalar or top-level list element. - The gap before the colon is bounded to the same line ([ \t]*:), so a blank line followed by a markdown definition-list ': *' can no longer bridge paragraphs. Detection gaps (#445): - The key may be quoted, catching JSON forms: "tools": ["*"] and "permissions": "*". - New block-list branch catches the idiomatic YAML form (tools: newline '- "*"'), bounded to a single newline with a standalone-star item so markdown lists of bold/italic names cannot collide. - A quoted '*' as a later top-level element of a same-line list now matches (tools: ["search", "*"]). The list branch excludes braces so a '*' nested inside an explicit tool object (tools: [{name: grep, pattern: "*"}]) is treated as an argument value for a named tool, not a wildcard grant. Validated against a 49-case matrix (20 genuine grant forms, 11 new detections, 18 false-positive classes); full suite 2966 passed, ruff check/format clean. Signed-off-by: yashrajbasav --- .../static_patterns_excessive_agency.py | 25 ++- .../test_ea1_wildcard_line_boundary.py | 144 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index f5a89e2f..42dc51ea 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -44,7 +44,30 @@ # 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 '{'/'}' so a '*' nested inside an explicit tool object + # (tools: [{name: grep, pattern: "*"}]) is an argument value, not a + # grant. 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..28bf4c65 100644 --- a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py +++ b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py @@ -114,3 +114,147 @@ 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) From d44a3f46fcc0d862f598cdbc8dd64538299e599d Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Mon, 31 Aug 2026 22:55:14 +0530 Subject: [PATCH 2/2] fix(ea1): keep the list scan out of nested arrays, not just objects The same-line list branch excluded '{'/'}' but still allowed '[', so a quoted '*' inside a nested array (tools: ["search", ["grep", "*"]]) was treated as a top-level tool-list element. Exclude '[' from the scan as well and document the quoted-delimiter ceiling. Adds the nested-array and nested-arguments-object negative regressions from review. Signed-off-by: yashrajbasav --- .../static_patterns_excessive_agency.py | 18 +++++++++++------- .../test_ea1_wildcard_line_boundary.py | 18 ++++++++++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index 42dc51ea..50fddd25 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -46,15 +46,19 @@ EA1_PATTERNS = [ # 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 '{'/'}' so a '*' nested inside an explicit tool object - # (tools: [{name: grep, pattern: "*"}]) is an argument value, not a - # grant. 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). + # 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"(?:\[[^\][{}\r\n]*['\"]\*['\"]" r"|\[?[ \t]*['\"]\*['\"]" r"|\[?[ \t]*\*(?![\*\w])[ \t]*\]?[ \t]*(?:#[^\r\n]*)?\r?$)", 0.85, diff --git a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py index 28bf4c65..2837d01c 100644 --- a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py +++ b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py @@ -258,3 +258,21 @@ def test_top_level_wildcard_after_named_element_still_flagged(self) -> None: "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)