/vuln-scan
Second leg of the defending-code loop (/threat-model → /vuln-scan →
/triage → /patch). Static vulnerability review of a source tree. Produces
VULN-FINDINGS.json (+ a human-readable .md) that /triage ingests
directly. Reads THREAT_MODEL.md if step 1 ran, and falls back to its own
recon if not — so this is a valid place to start the loop.
This skill does not execute code. It reads source and reasons about it.
For execution-verified findings (sanitizer crashes, reproducing PoCs), point
the user at an autonomous execution harness — see HARNESS.md in this skill
directory for the reference C/C++ + AddressSanitizer pipeline this group was
extracted from.
Tool fallbacks. Prefer the dedicated Glob and Grep tools. Some sessions
do not provision them — allowed-tools is a permission filter, not a loader,
so listing them here does not make them appear. When Glob/Grep are
unavailable, fall back to the read-only Bash commands whitelisted above:
rg --files <scope> / ls -R for enumeration, rg -n / grep -rn for
search, wc / head / file for sniffing, and codegraph explore
(read-only index query, Step 2). These are the ONLY permitted
Bash commands; do not write helper scripts or pipe target content into a
shell interpreter.
Arguments
<target-dir>(required) — directory to scan. Relative or absolute.--focus <area>— scan only this focus area (repeatable). Skips recon. Six names are reserved specialist lenses rather than subsystems:crypto,logic-bug,access-control,deserialization,batch-etl,iac. Each scopes the reviewer to the whole target seen through one vulnerability class, with a stricter cite-or-drop gate than the general bar (the lens bodies are in thevuln-area-revieweragent definition, so they cost one cached prefix per wave, not one per spawn). Any other value is a subsystem, as before. A lens name is only reserved when it is the entire argument —--focus "crypto (keystore.rs) — key derivation"is a subsystem whose name happens to start with a lens word.--single— no subagent fan-out; one sequential pass. Use on tiny targets or when debugging the prompt.--extra <file>— pass the contents of<file>to every reviewer as anEXTRA CHECKSsection of its per-spawn prompt (Step 2). Use to add org-specific vulnerability classes, compliance checks, or stack-specific patterns. Plain text; same shape as the category blocks in thevuln-area-revieweragent definition.--no-score— skip the Step 3b confidence pass (saves a round of subagents). Findings keep the scanner's self-reported confidence only.
Step 1 — Scope
Resolve
<target-dir>. If it doesn't exist or has no source files, stop with an error.Look for
<target-dir>/THREAT_MODEL.md. If present, parse its section 3 "Entry points & trust boundaries" table and section 4 "Threats" table for focus areas and threat classes. This is the preferred scoping input. Also parse its section 2 "Assets" table and section 1 system context into two bullet lists —assetsanddeployment_facts— for the review brief: severity calibration needs what the system protects, not just where input enters.Tag each focus area with the threat rows it came from. A section-4 row's
surfacecolumn names the section-3 entry point(s) it traverses; the focus area built from that entry point carriesthreat_ids: ["T1", "T4", ...]— every row whosesurfacenames it. Keep the rows' one-linethreattext alongside the ids: the reviewer needs the sentence, the output needs the id. A focus area derived from recon rather than from a threat row carriesthreat_ids: []— legitimate, and not the same as "no threats apply here".If no THREAT_MODEL.md and no
--focus: do a quick recon — list the source tree, read entry points and dispatch code, and propose 3-10 focus areas using the pattern<subsystem> (<function/file>) — <key operations>. During recon, also read any deploy manifests in the target (Helm chart, k8s YAML, compose file, Dockerfile env) and note asdeployment_factswhat is actually there: mounted secrets, auth in front, sessions/cookies, persistent state, tenancy. "Stateless, no auth, chart mounts no secret" changes every severity downstream.If
--focuswas given, use exactly those. For each value that is a reserved lens name, resolve it to the whole target rather than a subsystem, and check the lens has material before spawning:iacneeds infrastructure files (Terraform, Dockerfile, k8s/Helm YAML, CI pipeline definitions, Ansible, compose, CloudFormation);batch-etlneeds a job entry point rather than a request handler;deserializationneeds a deserializer call site anywhere in the tree. Skip a lens with no material and say so — "no IaC files under the target, skipping the iac lens" — rather than spending a reviewer to be told nothing is there.crypto,logic-bug, andaccess-controlapply to any code.Coverage rule — no threat drops out of scope. Only when step 2 ran (a THREAT_MODEL.md was read) and
--focuswas not given —--focusmeans "scan only this", and silently widening it would break that contract. Walk the section-4 rows: any row whosesurfacematches no section-3 entry point, or whose entry point produced no focus area, becomes its own focus area, phrased from the threat text and tagged with that row's id. Itsstatusdoes not exempt it: a row markedmitigatedis the author's claim, and confirming a control still holds in the code is review work. Only section 5 "Deprioritized" rows are out of scope — they were ruled out deliberately. A threat the model names and the scan never looks at is the failure this rule exists to prevent, and it is silent without it.
Tell the user the focus areas you'll scan and the source-file count before fanning out, and name any focus area added by rule 5 as threat-derived — it is the one the operator did not ask for.
Step 2 — Fan out
Unless --single, spawn one review subagent per focus area in parallel
— all Task calls in a SINGLE message, subagent_type: "vuln-area-reviewer"
(plugin installs: defending-code:vuln-area-reviewer). Cap at 10
concurrent. The full review brief is that agent definition's system prompt
— shared and prompt-cached across every reviewer in the wave — so each
spawn's prompt carries only the variable facts below. On tiny targets
(<15 source files), fall through to --single automatically.
Fallback: if neither agent name resolves, Read the agent definition at
../../agents/vuln-area-reviewer.md relative to this skill directory (same
layout in the repo and in a plugin install), and spawn general-purpose
subagents with its body pasted above the per-spawn block. In --single
mode, follow that same body inline, once per focus area sequentially.
Per-spawn prompt (variable facts only)
FOCUS AREA: **{focus_area}**
FINDING ID PREFIX: F-{focus_idx:02d}-
TARGET: {target_dir}
TRUST BOUNDARY: {from THREAT_MODEL.md section 3, or "untrusted input → process memory"}
{if this focus area has threat_ids:
THREATS THIS AREA IS SCOPED TO (from THREAT_MODEL.md section 4 — what the
model already predicted here; confirm or refute them in the code, and
report anything else you find as well):
{one bullet per row: "T4: <threat sentence> (status: <status>, controls: <controls>)"}}
ASSETS (what is worth protecting here):
{assets bullets from THREAT_MODEL.md section 2, or "(unknown)"}
DEPLOYMENT FACTS (what is actually deployed/mounted):
{deployment_facts bullets, or "(unknown)"}
{if --extra <file> was given:
EXTRA CHECKS:
<file contents verbatim>}
{if the target has a .codegraph/ index:
CALL GRAPH CONTEXT (mechanical index — a starting point, not evidence;
trace any flow you report by reading the code):
<excerpt>}
When {focus_area} is one of the six reserved lens names, that name alone
is the whole instruction — the agent body carries the lens and its gate.
Pass the bare name; do not paste the lens text into the tail, which would
un-share the one thing the cached prefix exists to share.
Call-graph context (only when the target is indexed)
If <target-dir>/.codegraph/ exists, give each reviewer a head start on
reachability: run
codegraph explore "<the focus area's functions/files from Step 1>" from
inside <target-dir> (e.g. cd <target-dir> && codegraph explore "..."),
trim the output to a compact excerpt — entry points, direct callers and
callees of the focus area's functions, ≤ ~40 lines; drop verbatim source
bodies — and append it to that reviewer's spawn prompt as the block above.
One query per focus area. If there is no .codegraph/ directory, or the
command fails, omit the block entirely and say nothing — never index the
target yourself.
Step 3 — Collate
- Collect
<finding>blocks from all subagents. Dropcategory=noneplaceholders. - Light dedupe — if two findings cite the same
file:linewith the same category, keep the one with the longer description and note the duplicate id. (Heavy dedupe is/triage's job; don't over-engineer here.) - Assign stable ids
F-001,F-002, ... in (severity desc, file, line) order. - Attach
threat_idsto each finding: thethreat_idsof the focus area whose reviewer produced it. This is mechanical inheritance from the spawn, not a judgment — the reviewer is not asked which threat its finding instantiates, and you do not infer one. Findings from an untagged focus area getthreat_ids: [].
Step 3a — Deterministic pre-filter (no subagents)
A mechanical gate between collation and the confidence pass. It exists because prompt rules alone are not enough — reviewers ignore the DO-NOT-REPORT list a measurable fraction of the time — and a finding that fails a check a script could run should never cost a confidence subagent. Nothing is dropped: gated findings stay in the output with a mechanically assigned confidence and reason, and skip Step 3b.
For each finding, in order:
Hallucinated path. Resolve
fileunder<target-dir>(as-given, then with common prefixes stripped). If it does not exist on disk: setconfidence: 0.05,prefilter: "hallucinated_path",confidence_reason: "prefilter: cited file not found under target". Skip 3b for it.Test/fixture path. Match the resolved path (case-insensitive) against:
(^|/)(tests?|__tests__|mocks?|examples?|fixtures?|samples?|testdata)(/|$) |_test\.|\.test\.|\.spec\.|Test\.java$|Tests\.cs$On match: set
confidence: 0.15,prefilter: "test_path",confidence_reason: "prefilter: test/example/fixture path". Skip 3b. Exception — committed credentials stay live: if the category ishardcoded-secretor the description evidences a committed credential (key material, token, password), do NOT gate it — a real secret in a fixture file is in source control and was likely real once. It proceeds to 3b at its reported confidence.Unproven data flow. If the finding carries neither
source_refnorsink_ref(§ Step 4), setprefilter: "unproven_flow". Unlike 1 and 2 this is an annotation, not a gate — the finding still goes to 3b and is still written out. It records that the reviewer asserted a flow in prose without naming its two ends, which is the shape a hallucinated flow takes, and it tells/triagethat this finding cannot be deduped on data-flow evidence. Never synthesize the refs yourself to clear the annotation: a ref the reviewer did not derive is not evidence.
All other findings get prefilter: null and proceed. Report the gate's
work in the terminal ("pre-filter: X hallucinated, Y test-path, Z
unproven-flow, W passed") — silent gating reads as "nothing was gated".
Step 3b — Confidence pass (skip if --no-score)
A cheap second-opinion read that ranks findings by signal quality.
Nothing is dropped — this pass calibrates confidence so humans and
/triage see high-signal findings first. Spawn one subagent per
finding that passed the Step 3a pre-filter in parallel — all Task calls
in one message, subagent_type: "vuln-confidence-scorer" (plugin installs:
defending-code:vuln-confidence-scorer). The scoring instructions are that
agent definition's cached system prompt; the fallback is as in Step 2, with
../../agents/vuln-confidence-scorer.md. Each spawn's prompt is only:
FINDING:
{the full <finding> block}
TARGET: {target_dir}
Resolve: overwrite each finding's confidence with the score
(normalized to 0.0-1.0) and attach confidence_reason. Re-sort findings
by (confidence desc, severity desc, file, line) and reassign ids
F-001.. in that order. Compute low_confidence_count = findings with
confidence < 0.4, for the summary line.
Step 4 — Write output
Write both files to <target-dir>/:
VULN-FINDINGS.json — the /triage ingest shape:
{
"target": "<target-dir>",
"scanned_at": "<iso8601>",
"focus_areas": ["..."],
"threat_coverage": {"T1": ["F-001"], "T2": [], "T5": ["F-004", "F-009"]},
"findings": [
{
"id": "F-001",
"file": "relative/path.c",
"line": 123,
"category": "heap-buffer-overflow",
"severity": "HIGH",
"confidence": 0.9,
"title": "...",
"description": "...",
"exploit_scenario": "...",
"recommendation": "...",
"source_ref": "relative/path.c:88",
"sink_ref": "relative/path.c:123",
"threat_ids": ["T1"],
"confidence_reason": "...",
"prefilter": null
}
],
"summary": {"total": 0, "high": 0, "medium": 0, "low": 0, "low_confidence": 0, "prefiltered": 0, "unproven_flow": 0}
}
source_ref / sink_ref are the two ends of the data flow the reviewer
traced — where untrusted input enters, and where it is used unsafely —
copied verbatim from its <finding> block, null when it named neither.
They are the finding's data-flow evidence: /triage deduplicates and
verifies against them instead of re-reading the prose, so two reviewers
who describe one bug from different angles collapse on matching refs.
Emit them as given; do not repair, infer, or normalize a ref the reviewer
did not produce. For a context-free finding (hardcoded secret, weak
constant) both point at the same location — that is correct, not a defect.
threat_ids is which THREAT_MODEL.md section-4 rows the finding's focus
area was scoped to — inherited from the spawn, so it says "this finding
came from work aimed at T4", not "this finding proves T4". threat_coverage
inverts it across the whole run: every section-4 row appears as a key,
including the ones no reviewer produced a finding for. An empty list is the
useful half — it says the scan looked at that threat and found nothing,
which is a result, and it is invisible if only matched threats are listed.
A row absent from the map entirely is a bug in Step 1 rule 5, not a clean
result.
In summary, prefiltered counts only findings the Step 3a gates
caught (rules 1-2, which skip 3b); unproven_flow counts the rule-3
annotation separately, since those findings were scored and kept.
Findings are sorted by confidence desc (then severity, file, line), so
the top of the file is the highest-signal material.
VULN-FINDINGS.md — human-readable: a summary table (id | severity |
category | file:line | title), then one ### F-NNN section per finding with
the full description, a **Flow:** {source_ref} -> {sink_ref} line (omit
when both are null), and a **Threats:** {threat_ids} line (omit when
empty). Close the file with a ## Threat coverage table — one row per
section-4 threat, its finding ids or none — when a THREAT_MODEL.md was
read.
Step 5 — Hand back
Tell the user:
- Counts: N findings (H/M/L split, X low-confidence, P pre-filtered, U without traced data flow), across K focus areas, from M source files.
- Top 3 by confidence, one line each.
- If a THREAT_MODEL.md was read: which section-4 threats got no findings, by id. Say it plainly — "the scan found nothing for T2, T6" — and that this is not the same as those threats being absent.
- Next step:
> /triage <target-dir>/VULN-FINDINGS.json --repo <target-dir> - Remind: these are static candidates, not verified. For execution-verified crashes, an autonomous harness is needed (see HARNESS.md).
Constraints
- Never execute target code. No builds, no
docker, no network, and no Bash beyond the read-only whitelist in § Tool fallbacks. If the user asks you to "reproduce" or "confirm with a PoC," decline and point at an autonomous execution harness (HARNESS.md). - Don't fabricate line numbers. Every
file:lineyou emit must be something you Read or Grep'd. If unsure of the exact line, cite the function and say so in the description. - Stay in
<target-dir>. Don't follow symlinks or..out of it. - Findings are candidates for
/triage, not final verdicts. This skill never drops a finding — Step 3b only ranks./triagedoes the rigorous N-vote verification and is where false positives actually get removed.
Provenance
Adapted (Apache-2.0) from
anthropics/defending-code-reference-harness
— the vuln-scan skill and its autonomous find/recon pipeline prompts.
The focus-area recon pattern and memory-safety quality tiers mirror the same
logic the autonomous pipeline uses, applied statically. The broader category
menu, DO-NOT-REPORT exclusions, per-finding confidence pass, and
exploit_scenario/recommendation output fields originate in
anthropics/claude-code-security-review's
/security-review command. The Step 3a deterministic pre-filter — the
test-path exclusion regex, the committed-credential exception to it, and
the gate-before-the-expensive-stage ordering — is adapted (Apache-2.0) from
visa/visa-vulnerability-agentic-harness's
s5 prefilter stage. From the same harness: the source_ref/sink_ref
evidence fields carry the semantics of its s4 finding schema, per-focus-area
threat tagging mirrors the threat_id its s3 stage stamps on every chunk,
and the six --focus specialist lenses — with the cite-or-drop gate that
is the transferable part of each — follow its SPECIALIST_HINTS set.
See HARNESS.md for the execution-verified pipeline this static skill
complements.