From a6b1d917fd8c0487a308ae2bfac0352bf42a8155 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Thu, 27 Aug 2026 12:11:02 +0530 Subject: [PATCH 1/4] 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 f5a89e2f4..42dc51ea4 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 876879fc4..28bf4c65d 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/4] 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 42dc51ea4..50fddd25a 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 28bf4c65d..2837d01ce 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) From d2d1d946c12431a1fd7be218e01455bc2fa61153 Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Wed, 14 Oct 2026 09:49:25 +0530 Subject: [PATCH 3/4] fix(ea1): match wildcard grants as complete list elements Signed-off-by: yashrajbasav --- .../static_patterns_excessive_agency.py | 21 +++------ .../test_ea1_wildcard_line_boundary.py | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index 6dc1af871..639170e22 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -45,22 +45,15 @@ # EA1: Unrestricted Tool Access EA1_CODE_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 '{', '}', 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). + # 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"(?:\[[^\][{}\r\n]*['\"]\*['\"]" - r"|\[?[ \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, ), diff --git a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py index 2837d01ce..c97d0bdf7 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, ) @@ -276,3 +278,47 @@ def test_json_tool_object_with_nested_arguments_not_flagged(self) -> None: "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) From 5330b9888405aa9a782a41be3308737f9e7eaa8a Mon Sep 17 00:00:00 2001 From: yashrajbasav Date: Wed, 14 Oct 2026 09:53:14 +0530 Subject: [PATCH 4/4] style(tests): format EA1 quoted-string cases Signed-off-by: yashrajbasav --- tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py index c97d0bdf7..82af78179 100644 --- a/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py +++ b/tests/nodes/analyzers/test_ea1_wildcard_line_boundary.py @@ -285,7 +285,7 @@ def test_json_tool_object_with_nested_arguments_not_flagged(self) -> None: [ "[\"grep --include='*'\"]", "['grep --include=\"*\"']", - "[\"search\", \"grep --include='*'\"]", + '["search", "grep --include=\'*\'"]', r'["grep --include=\"*\""]', "['grep --include=''*''']", '["*" + "suffix"]', @@ -313,7 +313,7 @@ def test_only_complete_top_level_wildcard_elements_are_flagged(value: str) -> No '[search, "*"]', '["search {x}", "*"]', '["search [x]", "*"]', - "[\"grep --include='*'\", \"*\"]", + '["grep --include=\'*\'", "*"]', r'["grep --include=\"*\"", "*"]', "['grep --include=''*''', '*']", '["search", "*", "grep"]',