Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -405,14 +405,15 @@ 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 |
|----|---------|----------|-------------|
| EA1 | Unrestricted Tool Access | HIGH | Unfettered tool access without constraints |
| 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)

Expand Down
4 changes: 4 additions & 0 deletions src/skillspector/nodes/analyzers/pattern_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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.",
Expand Down
210 changes: 207 additions & 3 deletions src/skillspector/nodes/analyzers/static_patterns_excessive_agency.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,19 +13,21 @@
# 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.
"""

from __future__ import annotations

import re
import shlex
import sys

from skillspector.logging_config import get_logger
Expand Down Expand Up @@ -155,9 +157,210 @@
),
]

# EA5: External Model or Provider Selection
_EA5_FRONTMATTER_KEY = re.compile(
r"^[\"']?(?P<key>model|provider|model_name|model_id)[\"']?[ \t]*:[ \t]*"
r"(?P<value>[^#\s][^#\r\n]*)",
re.IGNORECASE | re.MULTILINE,
)
_EA5_INLINE_CODE = re.compile(r"`(?P<command>[^`\r\n]+)`")
_EA5_IMPERATIVE_PREFIX = re.compile(
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<marker>`{3,}|~{3,})[ \t]*(?P<language>[\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])",
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 _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] = []
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
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]] = []
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):
Comment thread
deepujain marked this conversation as resolved.
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)
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:
Expand Down Expand Up @@ -229,6 +432,7 @@ def ctx(start: int) -> str:
matched_text=match.group(0)[:200],
)
)
findings.extend(_ea5_findings(content, file_path))
return findings


Expand Down
Loading
Loading