From 6b5a8499b798428c692e9eb398bad3580b82e8db Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Mon, 24 Aug 2026 07:12:41 -0700 Subject: [PATCH 1/2] feat(analyzer): detect external model selection Signed-off-by: Deepak Jain --- README.md | 7 +- .../nodes/analyzers/pattern_defaults.py | 4 + .../static_patterns_excessive_agency.py | 174 +++++++++++++++++- tests/unit/test_patterns_new.py | 72 +++++++- 4 files changed, 248 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index ed0c9d3f..a3af76b5 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ SkillSpector is part of the [NVIDIA Verified Skills pipeline](https://docs.nvidi ## Features - **Multi-format input**: Scan Git repos, URLs, zip files, directories, or single files -- **70 vulnerability patterns** across 17 categories: prompt injection, data exfiltration, privilege escalation, supply chain, excessive agency, output handling, system prompt leakage, memory poisoning, tool misuse, rogue agent, anti-refusal, trigger abuse, dangerous code (AST), taint tracking, YARA signatures, MCP least privilege, and MCP tool poisoning +- **71 vulnerability patterns** across 17 categories: prompt injection, data exfiltration, privilege escalation, supply chain, excessive agency, output handling, system prompt leakage, memory poisoning, tool misuse, rogue agent, anti-refusal, trigger abuse, dangerous code (AST), taint tracking, YARA signatures, MCP least privilege, and MCP tool poisoning - **Two-stage analysis**: Fast static analysis + optional LLM semantic evaluation - **Live vulnerability lookups**: SC4 queries [OSV.dev](https://osv.dev) for real-time CVE data with automatic offline fallback - **Multiple output formats**: Terminal, JSON, Markdown, and SARIF reports @@ -354,7 +354,7 @@ claude mcp add skillspector -- skillspector mcp ## Vulnerability Patterns -SkillSpector detects **70 vulnerability patterns** across 17 categories: +SkillSpector detects **71 vulnerability patterns** across 17 categories: ### Prompt Injection (6 patterns) @@ -405,7 +405,7 @@ SkillSpector detects **70 vulnerability patterns** across 17 categories: | SC8 | Shipped Python Bytecode | HIGH | `__pycache__` / `.pyc` present (discovery skips; malicious bytecode bypass) | | SC9 | Concealed Executable Artifact | HIGH | Executable nested in a document container or hidden/disguised artifact | -### Excessive Agency (4 patterns) +### Excessive Agency (5 patterns) | ID | Pattern | Severity | Description | |----|---------|----------|-------------| @@ -413,6 +413,7 @@ SkillSpector detects **70 vulnerability patterns** across 17 categories: | EA2 | Autonomous Decision Making | HIGH | High-impact decisions without human-in-the-loop | | EA3 | Scope Creep | MEDIUM | Capabilities extending beyond stated purpose | | EA4 | Unbounded Resource Access | MEDIUM | No rate limits or quotas on resource consumption | +| EA5 | External Model or Provider Selection | MEDIUM/HIGH | Model/provider pins or coding-CLI shell-outs that can switch billing accounts | ### Output Handling (3 patterns) diff --git a/src/skillspector/nodes/analyzers/pattern_defaults.py b/src/skillspector/nodes/analyzers/pattern_defaults.py index edbe2f7b..aa3b03c2 100644 --- a/src/skillspector/nodes/analyzers/pattern_defaults.py +++ b/src/skillspector/nodes/analyzers/pattern_defaults.py @@ -68,6 +68,7 @@ class PatternCategory(StrEnum): "EA2": "Skill enables autonomous high-impact decisions without human-in-the-loop verification. Critical operations (destructive commands, financial transactions, data deletion) should require explicit user confirmation.", "EA3": "Skill's behavior or capabilities extend beyond its stated purpose. Scope creep allows an agent to perform actions unrelated to its documented functionality, increasing the attack surface.", "EA4": "Skill allows unbounded resource consumption (API calls, storage, compute). Without rate limits or quotas, a compromised or misbehaving agent can cause denial-of-service or cost overruns.", + "EA5": "Skill selects an external model or provider that may use a different account or billing plan than the operator expects. Undisclosed model switches can cause unexpected cost or quota consumption.", # Output Handling (B.1.7) "OH1": "Model output is used without validation or sanitization. Unvalidated output injected into downstream contexts (SQL, shell, HTML) enables injection attacks and arbitrary code execution.", "OH2": "Output from one security context is used in another without boundary enforcement. Cross-context output flow can leak sensitive information or escalate privileges across trust boundaries.", @@ -177,6 +178,7 @@ class PatternCategory(StrEnum): "EA2": PatternCategory.EXCESSIVE_AGENCY.value, "EA3": PatternCategory.EXCESSIVE_AGENCY.value, "EA4": PatternCategory.EXCESSIVE_AGENCY.value, + "EA5": PatternCategory.EXCESSIVE_AGENCY.value, "OH1": PatternCategory.OUTPUT_HANDLING.value, "OH2": PatternCategory.OUTPUT_HANDLING.value, "OH3": PatternCategory.OUTPUT_HANDLING.value, @@ -264,6 +266,7 @@ class PatternCategory(StrEnum): "EA2": "Autonomous Decision Making", "EA3": "Scope Creep", "EA4": "Unbounded Resource Access", + "EA5": "External Model or Provider Selection", "OH1": "Unvalidated Output Injection", "OH2": "Cross-Context Output", "OH3": "Unbounded Output", @@ -351,6 +354,7 @@ class PatternCategory(StrEnum): "EA2": "Add human-in-the-loop confirmation for destructive, irreversible, or high-impact operations. Never auto-execute commands that modify files, send data, or alter system state.", "EA3": "Limit the skill's scope to its documented purpose. Remove instructions that enable the agent to perform actions outside its stated functionality.", "EA4": "Set explicit rate limits, timeouts, and resource quotas for API calls, file operations, and compute. Implement circuit breakers for runaway loops.", + "EA5": "Remove the model/provider override or disclose it prominently and require explicit operator approval before invoking an external coding CLI or billed model.", # Output Handling (B.1.7) "OH1": "Validate and sanitize all model output before using it in downstream contexts. Use parameterized queries for SQL, shell quoting for commands, and HTML encoding for web output.", "OH2": "Enforce strict context boundaries. Do not pass output from one security domain into another without explicit validation and redaction of sensitive content.", diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index 9c8c63a9..ccb860d0 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -13,12 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Static patterns: excessive agency (EA1–EA4). Node and analyze() in one module. +"""Static patterns: excessive agency (EA1–EA5). Node and analyze() in one module. Detects patterns where an agent skill grants unrestricted tool access (EA1), enables autonomous high-impact decisions without human-in-the-loop (EA2), exhibits scope creep beyond stated purpose (EA3), or allows unbounded -resource consumption (EA4). +resource consumption (EA4), or selects an external model/provider with billing +implications (EA5). Framework: LLM06, ASI02. """ @@ -26,6 +27,7 @@ from __future__ import annotations import re +import shlex import sys from skillspector.logging_config import get_logger @@ -155,9 +157,174 @@ ), ] +# EA5: External Model or Provider Selection +_EA5_FRONTMATTER_KEY = re.compile( + r"^[\"']?(?Pmodel|provider|model_name|model_id)[\"']?[ \t]*:[ \t]*" + r"(?P[^#\s][^#\r\n]*)", + re.IGNORECASE | re.MULTILINE, +) +_EA5_INLINE_CODE = re.compile(r"`(?P[^`\r\n]+)`") +_EA5_IMPERATIVE_PREFIX = re.compile( + r"^(?:run|execute|invoke|call)(?:[ \t]+the)?(?:[ \t]+command)?[ \t]*:?[ \t]+", + re.IGNORECASE, +) +_EA5_MODEL_VALUE = re.compile( + r"^(?:claude|gpt|gemini|deepseek|kimi|glm|minimax|mistral|llama)" + r"(?:$|[-_./:0-9])", + re.IGNORECASE, +) + + +def _frontmatter_bounds(content: str, file_path: str) -> tuple[int, int] | None: + """Return the YAML-frontmatter byte offsets for a SKILL.md file.""" + if file_path.rsplit("/", 1)[-1].lower() != "skill.md": + return None + opening = re.match(r"\A---[ \t]*\r?\n", content) + if opening is None: + return None + closing = re.search(r"^---[ \t]*$", content[opening.end() :], re.MULTILINE) + if closing is None: + return None + return opening.end(), opening.end() + closing.start() + + +def _is_model_switch_command(command: str) -> bool: + """Return whether a shell command selects another coding model/provider.""" + try: + tokens = shlex.split(command, comments=False, posix=True) + except ValueError: + tokens = command.split() + if not tokens: + return False + + executable = tokens[0].rsplit("/", 1)[-1].lower() + if executable == "claude" and any( + token in {"-p", "--print"} or token.startswith("--print=") for token in tokens[1:] + ): + return True + if executable == "codex" and len(tokens) > 1 and tokens[1].lower() == "exec": + return True + + if executable.startswith("python") or executable in {"node", "perl", "ruby"}: + return False + + for index, token in enumerate(tokens[1:], start=1): + value: str | None = None + if token in {"-m", "--model"} and index + 1 < len(tokens): + value = tokens[index + 1] + elif token.startswith(("-m=", "--model=")): + value = token.split("=", 1)[1] + if value and _EA5_MODEL_VALUE.match(value): + return True + return False + + +def _command_span(line: str) -> tuple[int, int] | None: + """Return the model-switch command span for an actionable instruction line.""" + # Inline code is handled separately so surrounding prose and punctuation do + # not become part of the command span (or create a duplicate finding). + if "`" in line: + return None + + leading = len(line) - len(line.lstrip(" \t")) + candidate = line[leading:] + if candidate.startswith(("$", ">")): + prompt_width = 1 + len(candidate[1:]) - len(candidate[1:].lstrip(" \t")) + leading += prompt_width + candidate = candidate[prompt_width:] + + command = candidate.strip().strip("`") + if _is_model_switch_command(command): + start = line.find(command, leading) + return start, start + len(command) + + imperative = _EA5_IMPERATIVE_PREFIX.match(candidate) + if imperative is not None: + command = candidate[imperative.end() :].strip().strip("`") + if _is_model_switch_command(command): + start = line.find(command, leading + imperative.end()) + return start, start + len(command) + return None + + +def _ea5_findings(content: str, file_path: str) -> list[AnalyzerFinding]: + """Detect declarative model pins and actionable coding-CLI model switches.""" + findings: list[AnalyzerFinding] = [] + tag = [PatternCategory.EXCESSIVE_AGENCY.value] + bounds = _frontmatter_bounds(content, file_path) + body_start = 0 + if bounds is not None: + start, end = bounds + body_start = end + frontmatter = content[start:end] + for match in _EA5_FRONTMATTER_KEY.finditer(frontmatter): + value = match.group("value").strip().lower() + if value in {'""', "''", "~", "null", "none", "default", "auto", "inherit"}: + continue + absolute_start = start + match.start() + key = match.group("key").lower() + findings.append( + AnalyzerFinding( + rule_id="EA5", + message="External Model or Provider Selection", + severity=Severity.MEDIUM, + location=Location( + file=file_path, + start_line=get_line_number(content, absolute_start), + ), + confidence=0.9, + tags=tag, + context=get_context(content, absolute_start), + matched_text=match.group(0)[:200], + evidence={"selection_surface": "frontmatter", "selection_key": key}, + ) + ) + + seen: set[tuple[int, int]] = set() + cursor = body_start + for line in content[body_start:].splitlines(keepends=True): + line_text = line.rstrip("\r\n") + candidates: list[tuple[int, int]] = [] + direct = _command_span(line_text) + if direct is not None: + candidates.append(direct) + for inline in _EA5_INLINE_CODE.finditer(line_text): + command = inline.group("command").strip() + if _is_model_switch_command(command): + command_offset = ( + inline.start("command") + + len(inline.group("command")) + - len(inline.group("command").lstrip()) + ) + candidates.append((command_offset, command_offset + len(command))) + + for line_start, line_end in candidates: + absolute = (cursor + line_start, cursor + line_end) + if absolute in seen: + continue + seen.add(absolute) + findings.append( + AnalyzerFinding( + rule_id="EA5", + message="External Model or Provider Selection", + severity=Severity.HIGH, + location=Location( + file=file_path, + start_line=get_line_number(content, absolute[0]), + ), + confidence=0.9, + tags=tag, + context=get_context(content, absolute[0]), + matched_text=content[absolute[0] : absolute[1]][:200], + evidence={"selection_surface": "command"}, + ) + ) + cursor += len(line) + return findings + def analyze(content: str, file_path: str, file_type: str) -> list[AnalyzerFinding]: - """Analyze content for excessive agency patterns (EA1–EA4).""" + """Analyze content for excessive agency patterns (EA1–EA5).""" findings: list[AnalyzerFinding] = [] def loc(ln: int) -> Location: @@ -229,6 +396,7 @@ def ctx(start: int) -> str: matched_text=match.group(0)[:200], ) ) + findings.extend(_ea5_findings(content, file_path)) return findings diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 63490fbf..22d24e34 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -15,7 +15,7 @@ """Pattern tests for static_patterns_* analyzer modules. -Covers: EA1–EA4, OH1–OH3, P6–P8, MP1–MP3, TM1–TM3, RA1–RA2, +Covers: EA1–EA5, OH1–OH3, P6–P8, MP1–MP3, TM1–TM3, RA1–RA2, SC4–SC6, TR1–TR3. """ @@ -78,11 +78,11 @@ def _analyze_deps(content: str, filename: str, osv_results: list | None = None) return sc_mod._analyze_dependencies(content, filename) -# ── Excessive Agency (EA1–EA4) ───────────────────────────────────────── +# ── Excessive Agency (EA1–EA5) ───────────────────────────────────────── class TestExcessiveAgency: - """EA1–EA4 detection.""" + """EA1–EA5 detection.""" @pytest.mark.parametrize( "content", @@ -237,6 +237,72 @@ def test_severity_levels(self) -> None: ea3 = ea_mod.analyze("Extend your scope beyond the stated purpose.", "SKILL.md", "markdown") assert all(f.severity == Severity.LOW for f in ea3 if f.rule_id == "EA3") + @pytest.mark.parametrize("key", ["model", "provider", "model_name", "model_id"]) + def test_ea5_frontmatter_pin_is_medium(self, key: str) -> None: + content = f"---\nname: example\n{key}: claude-sonnet-4-6\n---\n\n# Example\n" + findings = ea_mod.analyze(content, "SKILL.md", "markdown") + ea5 = [finding for finding in findings if finding.rule_id == "EA5"] + assert len(ea5) == 1 + assert ea5[0].severity == Severity.MEDIUM + assert ea5[0].location.start_line == 3 + assert ea5[0].evidence == { + "selection_surface": "frontmatter", + "selection_key": key, + } + + def test_ea5_only_matches_top_level_skill_frontmatter(self) -> None: + content = ( + "---\n" + "name: example\n" + "parameters:\n" + " model: user-selectable\n" + "---\n\n" + "The provider: field can be documented in prose.\n" + ) + findings = ea_mod.analyze(content, "SKILL.md", "markdown") + assert not any(finding.rule_id == "EA5" for finding in findings) + + @pytest.mark.parametrize("value", ['""', "''", "null", "~", "default", "inherit"]) + def test_ea5_empty_or_inherited_frontmatter_value_is_not_flagged(self, value: str) -> None: + content = f"---\nname: example\nmodel: {value}\n---\n" + findings = ea_mod.analyze(content, "SKILL.md", "markdown") + assert not any(finding.rule_id == "EA5" for finding in findings) + + @pytest.mark.parametrize( + "command", + [ + pytest.param("claude -p 'Review this diff'", id="claude_print"), + pytest.param("codex exec 'Fix the tests'", id="codex_exec"), + pytest.param("cmd -m claude-sonnet-4-6", id="generic_model_flag"), + pytest.param("cmd --model=gemini-2.5-pro", id="model_equals_flag"), + pytest.param("Run `codex exec 'Fix the tests'`", id="inline_instruction"), + pytest.param( + "Run `codex exec 'Fix the tests'`.", id="inline_instruction_with_punctuation" + ), + ], + ) + def test_ea5_coding_cli_switch_is_high(self, command: str) -> None: + findings = ea_mod.analyze(command, "SKILL.md", "markdown") + ea5 = [finding for finding in findings if finding.rule_id == "EA5"] + assert len(ea5) == 1 + assert ea5[0].severity == Severity.HIGH + assert ea5[0].evidence == {"selection_surface": "command"} + + @pytest.mark.parametrize( + "content", + [ + "The claude -p flag prints a response.", + "Compare Codex execution with other coding agents.", + "Use the user's currently selected model and provider.", + "model: claude-sonnet-4-6", + "python -m claude_tools", + "cmd --model custom-local-model", + ], + ) + def test_ea5_non_actionable_prose_and_body_keys_are_not_flagged(self, content: str) -> None: + findings = ea_mod.analyze(content, "SKILL.md", "markdown") + assert not any(finding.rule_id == "EA5" for finding in findings) + # ── Output Handling (OH1–OH3) ────────────────────────────────────────── From 052cd9f887673fcf10060a81d2787bea47fe3a67 Mon Sep 17 00:00:00 2001 From: Deepak Jain Date: Mon, 24 Aug 2026 11:15:34 -0700 Subject: [PATCH 2/2] fix(analyzer): require actionable inline model switches Signed-off-by: Deepak Jain --- .../static_patterns_excessive_agency.py | 58 +++++++++++++++---- tests/unit/test_patterns_new.py | 8 +++ 2 files changed, 55 insertions(+), 11 deletions(-) diff --git a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py index ccb860d0..f5a89e2f 100644 --- a/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py +++ b/src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py @@ -168,6 +168,13 @@ r"^(?:run|execute|invoke|call)(?:[ \t]+the)?(?:[ \t]+command)?[ \t]*:?[ \t]+", re.IGNORECASE, ) +_EA5_INLINE_DIRECTIVE_PREFIX = re.compile( + r"^(?:(?:[-*+]|\d+[.)])[ \t]+)?" + r"(?:run|execute|invoke|call)(?:[ \t]+the)?(?:[ \t]+command)?[ \t]*:?[ \t]*$", + re.IGNORECASE, +) +_EA5_FENCE = re.compile(r"^[ \t]*(?P`{3,}|~{3,})[ \t]*(?P[\w+-]*)") +_EA5_SHELL_FENCE_LANGUAGES = {"bash", "console", "sh", "shell", "zsh"} _EA5_MODEL_VALUE = re.compile( r"^(?:claude|gpt|gemini|deepseek|kimi|glm|minimax|mistral|llama)" r"(?:$|[-_./:0-9])", @@ -247,6 +254,21 @@ def _command_span(line: str) -> tuple[int, int] | None: return None +def _inline_command_span(line: str, inline: re.Match[str]) -> tuple[int, int] | None: + """Return an inline model-switch command only when prose directs its execution.""" + if _EA5_INLINE_DIRECTIVE_PREFIX.match(line[: inline.start()]) is None: + return None + command = inline.group("command").strip() + if not _is_model_switch_command(command): + return None + command_offset = ( + inline.start("command") + + len(inline.group("command")) + - len(inline.group("command").lstrip()) + ) + return command_offset, command_offset + len(command) + + def _ea5_findings(content: str, file_path: str) -> list[AnalyzerFinding]: """Detect declarative model pins and actionable coding-CLI model switches.""" findings: list[AnalyzerFinding] = [] @@ -282,21 +304,35 @@ def _ea5_findings(content: str, file_path: str) -> list[AnalyzerFinding]: seen: set[tuple[int, int]] = set() cursor = body_start + fence_marker: str | None = None + fence_language = "" for line in content[body_start:].splitlines(keepends=True): line_text = line.rstrip("\r\n") + fence = _EA5_FENCE.match(line_text) + if fence is not None: + marker = fence.group("marker") + if fence_marker is None: + fence_marker = marker[0] + fence_language = fence.group("language").lower() + elif marker[0] == fence_marker: + fence_marker = None + fence_language = "" + cursor += len(line) + continue + candidates: list[tuple[int, int]] = [] - direct = _command_span(line_text) - if direct is not None: - candidates.append(direct) + if ( + fence_marker is None + or fence_language in _EA5_SHELL_FENCE_LANGUAGES + or not fence_language + ): + direct = _command_span(line_text) + if direct is not None: + candidates.append(direct) for inline in _EA5_INLINE_CODE.finditer(line_text): - command = inline.group("command").strip() - if _is_model_switch_command(command): - command_offset = ( - inline.start("command") - + len(inline.group("command")) - - len(inline.group("command").lstrip()) - ) - candidates.append((command_offset, command_offset + len(command))) + command_span = _inline_command_span(line_text, inline) + if command_span is not None: + candidates.append(command_span) for line_start, line_end in candidates: absolute = (cursor + line_start, cursor + line_end) diff --git a/tests/unit/test_patterns_new.py b/tests/unit/test_patterns_new.py index 22d24e34..8c5672ba 100644 --- a/tests/unit/test_patterns_new.py +++ b/tests/unit/test_patterns_new.py @@ -292,6 +292,9 @@ def test_ea5_coding_cli_switch_is_high(self, command: str) -> None: "content", [ "The claude -p flag prints a response.", + 'The `codex exec "Fix tests"` command is documented below.', + "For example, `claude -p 'Review this diff'` prints a response.", + "```text\ncodex exec 'Fix the tests'\n```", "Compare Codex execution with other coding agents.", "Use the user's currently selected model and provider.", "model: claude-sonnet-4-6", @@ -303,6 +306,11 @@ def test_ea5_non_actionable_prose_and_body_keys_are_not_flagged(self, content: s findings = ea_mod.analyze(content, "SKILL.md", "markdown") assert not any(finding.rule_id == "EA5" for finding in findings) + def test_ea5_shell_fence_is_actionable(self) -> None: + content = "```shell\ncodex exec 'Fix the tests'\n```" + findings = ea_mod.analyze(content, "SKILL.md", "markdown") + assert any(finding.rule_id == "EA5" for finding in findings) + # ── Output Handling (OH1–OH3) ──────────────────────────────────────────