SkillGuard

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.

bash
# 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.

  1. 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
  2. Parse

    Every file walked and classified: Python into an AST, config into JSON or YAML, markdown as text.

    build_context
  3. 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
  4. Merge

    Two analyzers, one problem: the finding merges with both locations instead of counting twice.

    deduplicate
  5. Forget what you accepted

    Anything in your baseline never reaches the score, so later scans report only what is new.

    apply_baseline
  6. Score

    Severity points, times 1.3 if the skill ships executable scripts, capped at 100.

    score
  7. 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

SkillGuard rules and their severity
IDCategoryWhat it catchesSeverity
P1Prompt injectionInstruction override attemptHIGH
P2Prompt injectionInstruction to hide behaviour from the userHIGH
P3Prompt injectionInstruction to transmit data to an external endpointHIGH
AR1Anti-refusalRefusal suppressionHIGH
AR2Anti-refusalDisclaimer or warning suppressionHIGH
AR3Anti-refusalSafety policy nullificationHIGH
E1Data exfiltrationExternal transmission of dataHIGH
E2Data exfiltrationEnvironment variable harvestingHIGH
E3Data exfiltrationEnvironment file accessMEDIUM
E4Data exfiltrationShell upload of data to an external hostHIGH
PE1Privilege escalationWorld-writable permissionsMEDIUM
PE2Privilege escalationElevated privilege executionMEDIUM
PE3Privilege escalationCredential file accessHIGH
SC1Supply chainDependency has no version constraintLOW
SC2Supply chainRemote script piped into a shellHIGH
SC3Supply chainDecoded payload executedHIGH
SC4Supply chainDependency version has known vulnerabilitiesHIGH
SC6Supply chainDependency name looks like a popular packageHIGH
SC8Supply chainCompiled Python bytecode is shipped in the skillHIGH
SC9Supply chainExecutable disguised as a documentHIGH
SC10Supply chainDependency source redirectedHIGH
AST1Dangerous executionexec() executes arbitrary codeCRITICAL
AST2Dangerous executioneval() evaluates arbitrary expressionsHIGH
AST3Dangerous executionDynamic import of a computed module nameHIGH
AST4Dangerous executionSubprocess executionHIGH
AST5Dangerous executionOperating-system command executionHIGH
AST6Dangerous executioncompile() builds a code objectMEDIUM
AST7Dangerous executionDynamic attribute access hides the real callMEDIUM
AST8Dangerous executionExecution of a decoded or remote payloadCRITICAL
LP1MCP least privilegeServer implies a capability it does not declareMEDIUM
LP2MCP least privilegeServer requests a wildcard permissionHIGH
LP3MCP least privilegeServer declares no capabilitiesMEDIUM
TP1MCP tool poisoningHidden content in a tool descriptionHIGH
TP2MCP tool poisoningUnicode deception in a tool descriptionHIGH
TP3MCP tool poisoningInstruction-like text in a parameter descriptionHIGH
TM1Tool misuseShell execution with shell=TrueHIGH
TR1Trigger abuseOverly broad trigger descriptionLOW
TR2Trigger abuseSkill name shadows a built-in toolMEDIUM

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

CRITICAL50
HIGH25
MEDIUM10
LOW5

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 HIGH50 + 25 = 75
  • ships a .py script75 x 1.3 = 97
  • band 81 to 100CRITICAL, DO_NOT_INSTALL, exit 1

Bands, and what CI should do about them

ScoreSeverityRecommendationExit
0 to 20LOWSAFE0
21 to 50MEDIUMCAUTION0
51 to 80HIGHDO_NOT_INSTALL1
81 to 100CRITICALDO_NOT_INSTALL1

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.

.github/workflows/scan-skill.yml
- 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.