Methodology

How the leaderboard in leaderboard.md is produced, so that someone else can rerun it and get the same tables. Everything below is as of the run dates in the run log; this file is updated with every rerun.

What is measured

Each scanner is run, unmodified and at a pinned version, over the same set of skill directories. For every directory we keep both the scanner's own verdict and its full raw output. From that raw we derive, for every tool, its findings on one common severity scale, and then apply the same set of lenses to every tool.

A single detection number per scanner would not be comparable across tools, because each tool's own install gate means something different (Cisco's is_safe, NVIDIA's recommendation, Snyk's "any risk index", Tencent's SARIF level). So we report, for each tool, the fraction it flags under four lenses:

The native lens is read from the stored verdict; the severity lenses are recomputed from the stored raw output. Because every run already saves the raw output per sample, the lenses can be recomputed from an existing results/*.json with no scanner re-run (python -m score.report).

A sample that produced no parsable verdict is an error, and never counts as flagged under any lens. Errors stay in the denominator: a malicious package the scanner refuses to load is still a package the user is not warned about.

Native gates, by tool

Scanner Native-gate flag when
Cisco skill-scanner is_safe == false
NVIDIA SkillSpector risk_assessment.recommendation != "SAFE"
Snyk Agent Scan any non-null risk_index on the skill
Tencent AI-Infra-Guard any SARIF result at level error or warning
skill-lint (ours) any finding at medium or above

NVIDIA's documented install gate is a stricter threshold on a 0–100 risk score (block above 50); we report its recommendation as the native gate and do not recompute the score gate, so the native column overstates what SkillSpector would actually block on. The severity lenses do not depend on it. This is a deliberate choice, kept after checking its size: on the source-disjoint slice recommendation != SAFE fires on 87% of malicious and 69% of benign skills, almost all of them CAUTION on INFO-only findings, whereas a BLOCK-only gate fires on about 1% of each (see Replication check below). The recommendation is what a user sees in the tool's output, so it stays the native lens; the ≥ MEDIUM lens is the number to compare across tools.

Severity normalization

Each tool's findings are mapped to info < low < medium < high < critical:

Scanner Source of per-finding severity Mapping
Cisco findings[].severity direct (INFO–CRITICAL)
NVIDIA issues[].severity direct (INFO–CRITICAL)
Tencent SARIF level error → high, warning → medium, note → low
skill-lint findings[].severity direct (low/medium/high)
Snyk numeric risk_indexes[].score banded (see below)

Snyk exposes no severity enum, only a numeric score per risk index. We band that score onto the common scale (score ≥ 800 critical, ≥ 500 high, ≥ 200 medium, ≥ 1 low; the bands are in score/lenses.py). These bands are provisional — they decide Snyk's ≥ MEDIUM / ≥ HIGH numbers and have not been confirmed against Snyk's published risk-index scale, so those two Snyk cells are marked with a dagger (†). Snyk's native and ≥ any-finding lenses do not depend on the bands and are exact.

The scoring also keeps a coarser per-capability view for the canaries: flag (the native gate fired), noticed (a non-informational finding but the gate said safe), miss (nothing). Several scanners report MEDIUM findings and still recommend SAFE; "noticed" is what makes that visible.

Metrics

Corpus

Two parts, both under corpus/. Details and licenses are in corpus-sources.md.

Canaries (corpus/canaries/, 9, in-repo, authored here). Each exercises exactly one capability with no payload: inert by construction, in the spirit of EICAR. They measure whether a scanner flags a capability, independent of any known family.

Canary Behavior What it does
benign-control none prose SKILL.md, no code (expected clean)
read-canary-file file-read-outside-skill reads ~/.agent-bench-canary
write-tmp-file file-write-outside-skill writes one temp file
env-read env-read prints environment variable names
localhost-socket network-connect TCP connect to 127.0.0.1
http-localhost http-request HTTP GET to 127.0.0.1:9
spawn-echo subprocess-spawn runs echo
eval-literal dynamic-code-eval eval("1 + 1")
instruction-file-read instruction-file-read prose-only SKILL.md that tells the agent to read a file; no code

Manifest (corpus/manifest.yaml, 151 entries, by reference). No sample bytes are committed. Each entry carries a public source, a pinned commit, and a sha256; corpus/loader.py fetches into the gitignored corpus/cache/ and deletes anything whose hash differs.

Source-disjoint manifest (corpus/manifest-msb-source-disjoint.yaml, expands to 1,380 samples, by reference). MaliciousSkillBench's held-out test split: sources SRC009 / SRC011 / SRC012, 839 malicious + 541 benign (four benign rows are excluded because the dataset publishes redacted text for them with no hash to verify; corpus-sources.md names them). Labels, hashes and split membership come from the pinned benchmark_manifest.csv; the text comes from the Hugging Face dataset at revision d4b42ce5 and is written to the cache only if its sha256 matches exact_hash. This is the out-of-distribution slice; the SRC002 manifest above is the in-distribution ("home-ground") slice. Results carry a split field, and the leaderboard reports every lens per split.

The samples are never executed. Every scanner here is static or LLM-based and only reads files.

Scanners and how they are invoked

Scanner Version Invocation Local or remote
Cisco cisco-ai-skill-scanner 2.0.14 skill-scanner scan <dir> --format json --output-json <tmp> local; static analyzers plus a bundled ML model
NVIDIA SkillSpector v2.13.1 (git tag) skillspector scan <dir> --format json --output <tmp> --no-llm local static rules; one network call to OSV.dev for dependency files cannot be disabled
Snyk snyk-agent-scan 0.6.9 snyk-agent-scan scan <dir> --json remote: skill content is uploaded to api.snyk.io; needs SNYK_TOKEN
Tencent aig-skill-scan 0.2.2 aig-skill-scan --repo <dir> -o <sarif> --language en -m <model> -u <base_url> LLM-only: content goes to whatever model endpoint is configured
skill-lint (ours, ci/) 0.0.1 python -m ci.cli <dir> --json local regex and structural rules

Adapters shell out to the real CLIs and parse their native output; they do not reimplement any rule. Each result file and leaderboard row records version, exact flags, and run date.

Tencent has no detection logic of its own beyond the prompts it sends, so its row is named tencent@<model> and every model is a separate row: a local qwen3.5:9b-mlx on Ollama, and OpenRouter's free nvidia/nemotron-3.5-super-vl:free. The tool's default model (deepseek-v4-flash) has not been run; it is not free.

Snyk and Tencent send sample content to third parties. All manifest samples are already public, and the canaries are ours.

Results as of 2026-10-29

The tables in this section are the home-ground run (156 expected-flag: 148 wild + 8 canaries; 4 expected-clean). The source-disjoint slice was run the same day; its per-split tables are in leaderboard.md under By corpus split, and this section is to be rewritten around the two slices. Snyk's source-disjoint run hit the public Agent-Scan daily quota on 561 samples (reported as errors); runner.run --retry-errors rescans exactly those once the quota resets. The 2026-11-02 retry cleared the quota backlog, so Snyk's held-out figures are now final. The 9 errors that remain (6 source-disjoint, 3 home-ground) are permanent parse failures, not quota: Snyk refuses a SKILL.md with no name in its YAML frontmatter ("could not inspect skill"), and those stay in the denominator like any sample the scanner cannot load.

Detection (recall) by lens

Scanner Native gate ≥ any finding ≥ MEDIUM ≥ HIGH Errors
NVIDIA 2.13.1 88% (138/156) ‡ 88% (138/156) 88% (138/156) 71% (111/156) 0
Snyk 0.6.9 77% (120/156) 77% (120/156) 72% (113/156) † 51% (79/156) † 3
Cisco 2.0.14 5% (8/156) 66% (103/156) 10% (15/156) 5% (8/156) 8
Tencent @ qwen3.5-9b-mlx 7% (11/156) 7% (11/156) 7% (11/156) 7% (11/156) 0
Tencent @ nemotron-3.5 (free) partial (0/8) partial (0/8) partial (0/8) partial (0/8) 3

False-positive rate by lens

Scanner Native gate ≥ any finding ≥ MEDIUM ≥ HIGH
NVIDIA 2.13.1 50% (2/4) ‡ 50% (2/4) 50% (2/4) 25% (1/4)
Snyk 0.6.9 25% (1/4) 25% (1/4) 0% (0/4) † 0% (0/4) †
Cisco 2.0.14 0% (0/4) 25% (1/4) 25% (1/4) 0% (0/4)
Tencent (both models) 0% (0/4) 0% (0/4) 0% (0/4) 0% (0/4)

† provisional Snyk band (see Severity normalization). ‡ NVIDIA native gate is recommendation, not its numeric install gate.

Reference — skill-lint (ours), not a neutral entry. skill-lint is this repo's own linter; we wrote both it and the corpus, so it is kept off the comparison above. For reference its recall is 78% / 83% / 78% / 61% across the four lenses, with 2/4 FPR.

Per-behavior coverage

The full flag/noticed/miss grid per canary behavior is in leaderboard.md. In brief: presented with a single capability and no payload, no scanner flags a loopback connection, an outside-skill file read, a subprocess, or a temp-file write. Only eval (Cisco, NVIDIA) and the prose-only file-read instruction (Snyk) fire. On the wild family: Cisco 7/148 flag (94 more noticed below its gate), NVIDIA 137/148, Snyk 119/148, Tencent @ Qwen 11/148.

What the numbers say, and what they do not

Published comparisons

Every number below that includes SkillSpector or Cisco was published by someone other than NVIDIA. NVIDIA has published no comparison of SkillSpector against other scanners: its repository carries one script, scripts/compare_scan_accuracy.py, that compares two SkillSpector revisions on a human-adjudicated corpus kept outside the repo, a release regression gate rather than a benchmark. Static-only rows, recall / FPR:

Source Date SkillSpector Cisco Gate
MaliciousSkillBench paper, Table 3, source-disjoint (arXiv 2608.19901) 2026-08 0.0% / 0.6% 2.5% / 1.1% SkillSpector BLOCK; Cisco local HIGH/CRITICAL
Cisco-side HF space, static arm, source-disjoint (Vineethsain) 2026-10-16 1.3% / 1.5% (BLOCK); 34.3% / 42.5% (≥ MEDIUM) 7.8% / 7.6% both
Liatrio, 65 wild MalSkills + 100 benign (blog) 2026-08-12 1.5% / 10.0% 9.7% / 12.2% tool default
This benchmark, source-disjoint 2026-10-29 87.2% / 69.3% (recommendation); 31.6% / 42.0% (≥ MEDIUM) 3.9% / 1.7% both

Read together: the widely quoted "0% static" for SkillSpector is the paper's BLOCK-gate figure; at ≥ MEDIUM the space and this benchmark agree. Every published Cisco static number sits a few points above ours, with the space's source-checkout rule pack the outlier. None of the three publishes code and per-sample results together: the paper gives a table, Liatrio a post without code or raw output, the space data files whose reproduce steps live in the Cisco scanner's own tree. Liatrio and SkillSecurer (arXiv 2609.14079) also compare the tools LLM-backed, where both exceed 90% recall at 20–28% FPR; those are not static measurements and are not reproduced here.

Replication check against a published static run

One other static run over the same MSB source-disjoint split is public: Vineethsain's Hugging Face space Cisco Skill Scanner vs NVIDIA SkillSpector (2026-10-16), a Cisco-side study whose reproduce steps live in the Cisco scanner's own evals/ tree. Its static arm ran Cisco at source commit 4f8bec3 and SkillSpector v2.11.2 over 1,368 records both tools scored; we ran the pip release 2.0.14 and SkillSpector 2.13.1 over 1,380. Same split, same four lens shapes, independent harnesses (docs/figures/replication-vs-space.png):

Tool · lens This benchmark (recall / FPR) HF space static arm
SkillSpector · ≥ MEDIUM 31.6% / 42.0% 34.3% / 42.5%
SkillSpector · ≥ HIGH 11.8% / 14.8% 14.5% / 15.3%
SkillSpector · shipped gate 87.2% / 69.3% (recommendation) 1.3% / 1.5% (BLOCK only)
Cisco · ≥ MEDIUM 4.5% / 2.2% 8.0% / 8.3%
Cisco · any finding 5.0% / 3.5% (LOW+) 60.6% / 87.0% (incl. INFO)

SkillSpector replicates within three points at ≥ MEDIUM and ≥ HIGH across two harnesses and two versions; the shipped-gate row differs only in the lens definition (above). Cisco does not replicate: the space's numbers are higher at every lens. Two differences are known and either could explain it: the rule set in play (a source checkout with the "shipped core rule pack" versus the pip release) and the any-finding lens (ours starts at LOW, theirs counts INFO). Resolving it needs both sides pinned to the same release; until then our Cisco row is the lower of two published measurements. Both harnesses also see SkillSpector report most MSB records as partial analysis (1,043 of 1,384 in the space), because each SKILL.md references files the dataset does not ship; coverage of the shipped text is still complete.

Known limitations

Reproducing

uv venv -p 3.12 .venv
uv pip install -p .venv/bin/python -e ".[cisco,nvidia,snyk,tencent,dev]"
.venv/bin/python -m corpus.loader verify     # fetches the manifest into corpus/cache/
uv pip install -p .venv/bin/python datasets  # the source-disjoint slice is HF-hosted
M="--manifest corpus/manifest.yaml --manifest corpus/manifest-msb-source-disjoint.yaml"
                                              # omit $M to scan the canaries only
.venv/bin/python -m runner.run --scanner cisco $M
.venv/bin/python -m runner.run --scanner nvidia $M
SNYK_TOKEN=... .venv/bin/python -m runner.run --scanner snyk $M
LLM_API_KEY=ollama LLM_BASE_URL=http://localhost:11434/v1 LLM_MODEL=qwen3.5:9b-mlx \
  .venv/bin/python -m runner.run --scanner tencent $M
.venv/bin/python -m runner.run --scanner skill-lint $M
.venv/bin/python -m score.report              # recomputes every lens from results/*.json

loader verify downloads a tarball of live malicious skill packages into a gitignored directory, and --manifest is what puts them in front of a scanner. Do both in a disposable VM; without the flag the runner touches only the inert canaries. The static scanners take minutes over the full corpus; the local 9B-parameter LLM took about three and a half hours for the 160-sample home-ground run alone, which is why the Tencent rows do not yet cover the source-disjoint slice. Both slices must come from one run per scanner (--manifest is repeatable), because the report takes the latest result file per scanner. Snyk's public tier has a daily quota below the corpus size: rerun with --retry-errors results/snyk-<date>.json the next day to fill in the samples it refused.

Because every lens is recomputed from the raw stored in each result file, changing the scoring or adding a lens does not require re-running any scanner — only python -m score.report. Every cell in the leaderboard traces to the tool's own JSON or SARIF in results/<scanner>-<date>.json.

Scope

This repository measures scanners. It does not author malware, build evasion techniques, or redistribute samples; the full boundary is in CLAUDE.md. Canaries are inert and labelled as such in their own SKILL.md. Real samples are cited by hash and public source only.

Run log

Date Change
2026-10-27 First runs: Cisco, NVIDIA, Snyk, Tencent (Qwen local, Nemotron free) over 9 canaries + 3 benign
2026-10-28 All but Nemotron rerun over the full manifest (148 wild malicious added)
2026-10-29 Scoring rebuilt around consistent lenses (native / ≥ any / ≥ MEDIUM / ≥ HIGH) applied identically to every tool; recomputed from the stored raw with no scanner re-run
2026-10-29 Figures (python -m score.figures → docs/figures/) and a replication check against the one public static run on the same split; NVIDIA native lens kept as recommendation, difference to a BLOCK-only gate documented
2026-10-29 Source-disjoint slice added (MSB held-out split, 839 + 541, HF revision d4b42ce5); Cisco, NVIDIA, Snyk and skill-lint run once over both slices. Snyk: 561 source-disjoint samples errored on the public daily quota, to be retried
2026-10-31 Snyk --retry-errors over the 29th's errors: 45 scored, 517 hit the daily quota again, 6 are permanent parse failures (no name in frontmatter). Leaderboard and figures regenerated
2026-11-02 Snyk --retry-errors cleared the remaining quota backlog: 514 scored, 9 permanent parse failures left (no name in frontmatter). Held-out recall rose from 27% to 42% as the deflating quota errors left the denominator. Leaderboard and figures regenerated
An independent, rerunnable benchmark. Every in-house number regenerates with python -m score.report. · Back to the explorer