Skill Scanner can be embedded directly in Python applications.
from skill_scanner import SkillScanner
from skill_scanner.core.analyzers import BehavioralAnalyzer
scanner = SkillScanner(analyzers=[BehavioralAnalyzer()])
result = scanner.scan_skill("/path/to/skill")
print(result.skill_name)
print(result.max_severity)
print(len(result.findings))SkillScanner(
analyzers=None, # List[BaseAnalyzer] — custom analyzers; None uses defaults
use_virustotal=False, # Enable VirusTotal binary scanning
virustotal_api_key=None, # VirusTotal API key (required when use_virustotal=True)
virustotal_upload_files=False, # Upload unknown files to VT (vs hash-only lookup)
policy=None, # ScanPolicy instance; None loads built-in defaults
)When analyzers is None, the scanner builds the default core analyzer set (static, bytecode, pipeline). Pass an explicit list to control exactly which analyzers run.
Scan a single skill package directory. Pass lenient=True to coerce malformed manifests instead of raising an error. When lenient=True and no SKILL.md exists, the loader falls back to scanning .md files in the directory. Pass skill_file to use a custom metadata filename (e.g. "README.md").
result = scanner.scan_skill("/path/to/skill")
# Scan a directory without SKILL.md (e.g. Claude Code commands)
result = scanner.scan_skill(".claude/commands/deploy", lenient=True)
# Use a custom metadata file
result = scanner.scan_skill("/path/to/skill", skill_file="README.md")scan_directory(skills_directory, recursive=False, check_overlap=False, *, lenient=False, skill_file=None) → Report
Scan all skill packages in a directory. When lenient=True, directories containing .md files (but no SKILL.md) are also discovered as candidate skills.
report = scanner.scan_directory("/path/to/skills", recursive=True)
print(report.total_skills_scanned)
print(report.total_findings)
# Discover and scan non-standard skill formats
report = scanner.scan_directory(".claude/commands", recursive=True, lenient=True)Add an analyzer to the scanner at runtime.
import os
from skill_scanner.core.analyzers import LLMAnalyzer
scanner.add_analyzer(LLMAnalyzer(
model="anthropic/claude-sonnet-4-20250514",
api_key=os.environ["SKILL_SCANNER_LLM_API_KEY"],
))Warning
Never hardcode API keys in source code. Use environment variables or a secrets manager. The LLM analyzer also reads SKILL_SCANNER_LLM_API_KEY from the environment automatically when no api_key is passed.
For OpenAI-compatible endpoints that require a Chat Completions user value, set SKILL_SCANNER_LLM_USER or pass llm_user=os.environ.get("SKILL_SCANNER_LLM_USER").
Return names of all configured analyzers.
print(scanner.list_analyzers())
# ['static_analyzer', 'bytecode', 'pipeline']For one-off scans without managing a scanner instance:
from skill_scanner import scan_skill, scan_directory
result = scan_skill("/path/to/skill")
report = scan_directory("/path/to/skills", recursive=True, check_overlap=True)Both functions accept an optional analyzers list and policy parameter.
ScanResult attributes (single skill)
| Attribute | Type | Description |
|---|---|---|
skill_name |
str |
Name from SKILL.md manifest |
skill_directory |
str |
Absolute path to the scanned skill |
findings |
list[Finding] |
All security findings |
scan_duration_seconds |
float |
Wall-clock scan time |
analyzers_used |
list[str] |
Analyzer names that ran |
analyzability_score |
float | None |
Percentage of content the scanner could inspect |
is_safe |
bool |
True when no CRITICAL or HIGH findings |
max_severity |
Severity |
Highest severity across all findings |
result.get_findings_by_severity(Severity.HIGH)
result.get_findings_by_category(ThreatCategory.DATA_EXFILTRATION)
result.to_dict() # Serialize to JSON-compatible dictReport attributes (multi-skill)
| Attribute | Type | Description |
|---|---|---|
scan_results |
list[ScanResult] |
Per-skill results |
total_skills_scanned |
int |
Number of skills processed |
total_findings |
int |
Sum of all findings |
critical_count |
int |
Total CRITICAL findings |
high_count |
int |
Total HIGH findings |
medium_count |
int |
Total MEDIUM findings |
low_count |
int |
Total LOW findings |
info_count |
int |
Total INFO findings |
safe_count |
int |
Skills with is_safe == True |
Finding attributes
| Attribute | Type | Description |
|---|---|---|
id |
str |
Unique finding identifier (rule ID + content hash) |
rule_id |
str |
Rule identifier (e.g. DATA_EXFIL_HTTP_POST) |
category |
ThreatCategory |
Threat category enum |
severity |
Severity |
Severity level |
title |
str |
Human-readable title |
description |
str |
Detailed explanation |
file_path |
str | None |
Relative path within the skill |
line_number |
int | None |
Line number (when available) |
snippet |
str | None |
Code snippet context |
remediation |
str | None |
Suggested fix |
analyzer |
str | None |
Which analyzer produced this finding |
metadata |
dict |
Extra context (YARA rule name, matched pattern, threat type, etc.) |
for finding in result.findings:
print(finding.rule_id, finding.severity.value, finding.file_path)Values in descending order: CRITICAL, HIGH, MEDIUM, LOW, INFO, SAFE.
from skill_scanner import SkillScanner
from skill_scanner.core.scan_policy import ScanPolicy
# Use a built-in preset
policy = ScanPolicy.from_preset("strict")
# Or load a custom YAML file
policy = ScanPolicy.from_yaml("my_policy.yaml")
scanner = SkillScanner(policy=policy)
result = scanner.scan_skill("/path/to/skill")Typical analyzers are configured through build_analyzers in skill_scanner/core/analyzer_factory.py, but direct analyzer construction is also possible for custom runtime control.
- examples/basic_scan.py
- examples/programmatic_usage.py
- examples/advanced_scanning.py
- examples/batch_scanning.py
- CLI Usage — command-line interface for the same scanning engine
- API Server — REST API for upload-driven workflows
- Configuration Reference — all configuration options