Read the skill before you trust it.
An agent skill is a stranger's instructions running with your credentials. SkillGuard reads the markdown, parses the Python, checks the JSON and YAML, and hands back a verdict with the line number that caused it.
# needs uv (docs.astral.sh/uv)
git clone https://github.com/NilLab-agi/SkillGuard
cd SkillGuard && uv sync --all-extras
uv run skillguard scan ./that-skill-you-found/
- Rules
- 38
- Categories
- 10
- Analyzers
- 12
- Formats
- 4
01 / How it reads
Same input, same report.
One scan is one pass over a folder: parse, analyze, dedupe, score, print. Nothing is decided by a model unless you invite one in, and the analysis is ordinary code you can read.
-
Resolve
A folder, one
.md, a zip, or a git URL. Size caps and zip-traversal checks land before a byte is read.resolve_input -
Parse
Every file walked and classified: Python into an AST, config into JSON or YAML, markdown as text.
build_context -
Analyze
Twelve analyzers read the same context and append to one findings list. None of them can delete a finding.
static_runner to 12 analyzers -
Merge
Two analyzers, one problem: the finding merges with both locations instead of counting twice.
deduplicate -
Forget what you accepted
Anything in your baseline never reaches the score, so later scans report only what is new.
apply_baseline -
Score
Severity points, times 1.3 if the skill ships executable scripts, capped at 100.
score -
Print
Terminal, JSON, Markdown or SARIF. The JSON shape is the contract other tools build on.
report
What the twelve look for
- Prompt injection
- Anti-refusal
- Data exfiltration
- Privilege escalation
- Dangerous execution (AST)
- Supply chain patterns
- Dependencies
- Shipped files
- Known CVEs --osv
- MCP config
- MCP least privilege
- MCP tool poisoning
The Python analysis is behavioral, because indirection is the whole trick:
getattr(os, "system") trips the same rule as os.system.
02 / Coverage
38 rules, ten categories, no black box
Every finding names the rule that fired and the line it fired on. This is the entire rule set, so filter it and see for yourself.
Showing 38 of 38
| ID | Category | What it catches | Severity |
|---|---|---|---|
| P1 | Prompt injection | Instruction override attempt | HIGH |
| P2 | Prompt injection | Instruction to hide behaviour from the user | HIGH |
| P3 | Prompt injection | Instruction to transmit data to an external endpoint | HIGH |
| AR1 | Anti-refusal | Refusal suppression | HIGH |
| AR2 | Anti-refusal | Disclaimer or warning suppression | HIGH |
| AR3 | Anti-refusal | Safety policy nullification | HIGH |
| E1 | Data exfiltration | External transmission of data | HIGH |
| E2 | Data exfiltration | Environment variable harvesting | HIGH |
| E3 | Data exfiltration | Environment file access | MEDIUM |
| E4 | Data exfiltration | Shell upload of data to an external host | HIGH |
| PE1 | Privilege escalation | World-writable permissions | MEDIUM |
| PE2 | Privilege escalation | Elevated privilege execution | MEDIUM |
| PE3 | Privilege escalation | Credential file access | HIGH |
| SC1 | Supply chain | Dependency has no version constraint | LOW |
| SC2 | Supply chain | Remote script piped into a shell | HIGH |
| SC3 | Supply chain | Decoded payload executed | HIGH |
| SC4 | Supply chain | Dependency version has known vulnerabilities | HIGH |
| SC6 | Supply chain | Dependency name looks like a popular package | HIGH |
| SC8 | Supply chain | Compiled Python bytecode is shipped in the skill | HIGH |
| SC9 | Supply chain | Executable disguised as a document | HIGH |
| SC10 | Supply chain | Dependency source redirected | HIGH |
| AST1 | Dangerous execution | exec() executes arbitrary code | CRITICAL |
| AST2 | Dangerous execution | eval() evaluates arbitrary expressions | HIGH |
| AST3 | Dangerous execution | Dynamic import of a computed module name | HIGH |
| AST4 | Dangerous execution | Subprocess execution | HIGH |
| AST5 | Dangerous execution | Operating-system command execution | HIGH |
| AST6 | Dangerous execution | compile() builds a code object | MEDIUM |
| AST7 | Dangerous execution | Dynamic attribute access hides the real call | MEDIUM |
| AST8 | Dangerous execution | Execution of a decoded or remote payload | CRITICAL |
| LP1 | MCP least privilege | Server implies a capability it does not declare | MEDIUM |
| LP2 | MCP least privilege | Server requests a wildcard permission | HIGH |
| LP3 | MCP least privilege | Server declares no capabilities | MEDIUM |
| TP1 | MCP tool poisoning | Hidden content in a tool description | HIGH |
| TP2 | MCP tool poisoning | Unicode deception in a tool description | HIGH |
| TP3 | MCP tool poisoning | Instruction-like text in a parameter description | HIGH |
| TM1 | Tool misuse | Shell execution with shell=True | HIGH |
| TR1 | Trigger abuse | Overly broad trigger description | LOW |
| TR2 | Trigger abuse | Skill name shadows a built-in tool | MEDIUM |
Rule ids are stable, which is what makes them usable: your baseline file and every SARIF result key on them, so renaming one would break your history.
03 / The score
Arithmetic, not a vibe.
A model never touches this. Add the points, apply the multiplier, clamp, read the band. Every score is reproducible by hand.
Points per finding
| CRITICAL | 50 |
| HIGH | 25 |
| MEDIUM | 10 |
| LOW | 5 |
Baselined findings are dropped before the sum, not scored and hidden, so accepting one is a real decision.
The multiplier
Everything weighs 30% more when the skill ships scripts that can execute:
.py .sh .bash .zsh .js .mjs .cjs .ts .rb .pl .ps1 .bat
Worked example
- 1 CRITICAL + 1 HIGH
50 + 25 = 75 - ships a .py script
75 x 1.3 = 97 - band 81 to 100
CRITICAL, DO_NOT_INSTALL, exit 1
Bands, and what CI should do about them
| Score | Severity | Recommendation | Exit |
|---|---|---|---|
| 0 to 20 | LOW | SAFE | 0 |
| 21 to 50 | MEDIUM | CAUTION | 0 |
| 51 to 80 | HIGH | DO_NOT_INSTALL | 1 |
| 81 to 100 | CRITICAL | DO_NOT_INSTALL | 1 |
Exit 2 means the scan itself failed. A crash and a hostile skill are different results, and your pipeline should be able to tell them apart.
04 / Ship it
Make the score a gate.
Scan the repo on every push, and fail the build when the score crosses 50. The finding shows up in the pull request as a code scanning alert, next to the line that caused it.
- uses: astral-sh/setup-uv@v5
- run: uv sync --all-extras
# SARIF first, so alerts exist even when the gate fails the build
- run: uv run skillguard scan . --format sarif -o skillguard.sarif
continue-on-error: true
- uses: github/codeql-action/upload-sarif@v3
with: { sarif_file: skillguard.sarif }
# the gate: exits 1 above 50
- run: uv run skillguard scan .
Reports come out as terminal, json, markdown or
sarif. Add --fail-on-findings to fail on any finding at all. The
complete workflow lives in
docs/examples/scan-skill.yml.