triage
Third leg of the defending-code loop (/threat-model → /vuln-scan →
/triage → /patch). Consumes /vuln-scan's VULN-FINDINGS.json, but
any scanner output works, so this is also a valid standalone entry point for an
existing backlog.
Adversarial triage of raw security-scanner output. Does four jobs: verify each finding is real, deduplicate across runs and scanners, rank survivors by what the attacker actually gains against a named asset times how easily they reach it — not by the scanner's claimed severity — and route each to a component owner. Output is a short, ranked, owned list instead of a raw dump.
Invoke with /triage <findings-path> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE].
Arguments (parse from $ARGUMENTS; positional $1/$2 expansion is
not stable across runtimes):
- findings path (first positional, required): a JSON file, a directory of
JSON files, a
VULN-FINDINGS.json, a pipelineresults/<target>/<ts>/directory, or a markdown report. --auto: skip the interview and use defaults. Default mode is interactive.--votes N: verifier votes per finding (default 3; use 1 for a quick pass, 5 for high-stakes batches).--repo PATH: path to the target codebase, read-only (default cwd). Verification needs source access; the skill stops with an error if the cited files aren't reachable.--fp-rules FILE: append the contents of FILE to the verifier's exclusion-rule list (Phase 3a). Use for org-specific precedents: "we use Prisma ORM everywhere — raw-query SQLi only", "k8s resource limits cover DoS", etc. Plain text, one rule per line or paragraph.--fresh: ignore any existing checkpoint in./.triage-state/and start from Phase 0. Without this flag the skill resumes from the last completed phase if a checkpoint is present.
Tools: Read, Glob, Grep, Write, Task, AskUserQuestion. Bash is
permitted only for git, find, wc, ls, jq,
codegraph explore (read-only index query, Phase 3a), and
python3 .claude/skills/triage/scripts/checkpoint.py (checkpoint I/O).
Do not execute target code. No building, running, installing
dependencies, or sending requests. A proof-of-concept that accidentally
works against something real is unacceptable, and "couldn't write a working
PoC" is weak evidence of non-exploitability. Every conclusion comes from
reading source. This applies to the orchestrator and every subagent: the
triage-verifier / triage-ranker agent definitions carry it in their
system prompts and read-only tool lists; for any general-purpose spawn
(the 2b dedupe agent, or the fallback path), include the constraint in the
Task prompt. For high-confidence HIGH findings, recommend a human-built
PoC as a follow-up instead.
Do not reach the network. No package-registry lookups, CVE-database queries, or upstream-commit fetches.
Checkpointing (runs before Phase 0 and after every phase)
On large finding batches a full run can exhaust context or hit rate limits
mid-way — particularly Phase 3, which spawns candidates × votes verifiers.
Phase state persists to ./.triage-state/ so a fresh /triage session can
resume without re-asking the interview or re-spawning verifiers.
All checkpoint I/O goes through python3 .claude/skills/triage/scripts/checkpoint.py
(atomic writes, JSON-validated). Never use the Write tool for progress.json
directly. Never pass payload via heredoc or stdin; target-derived strings
could collide with the heredoc delimiter and break out to shell. The
Write→--from pattern keeps repo-derived bytes out of Bash argv.
State files in ./.triage-state/:
progress.json— single source of truth for resume position:{"status": "running"|"complete", "phase_done": N, "shards_done": [...]}. Resume decisions read ONLY this file, never a glob ofphase*.jsonor shard files (stale files from a prior run must not be trusted).phaseN.json— data payload for phase N (schemas at the tail of each phase section below)._chunk.tmp— transient payload buffer; overwritten before everysave/shard/appendcall.
Start of run — resume check. Bash:
python3 .claude/skills/triage/scripts/checkpoint.py load ./.triage-state
status == "absent"OR"complete", OR--freshin$ARGUMENTS→ fresh start. Bash:python3 .claude/skills/triage/scripts/checkpoint.py reset ./.triage-state, then proceed to Phase 0.status == "running"withphase_done == N→ resume. Read./.triage-state/phase0.jsonthroughphaseN.jsonin order (and anyshard_*.jsonfiles listed inshards_done), merging keys into working state (later files override earlier — checkpoints may be deltas). PrintResuming from checkpoint: Phase N complete (./.triage-state/phaseN.json), and skip directly to Phase N+1.
End of every phase N. Two tool calls:
- Write tool →
./.triage-state/_chunk.tmpcontaining the phase's output JSON (schema at the tail of each phase section). - Bash →
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state <N> <name> --from ./.triage-state/_chunk.tmp
End of run. After writing TRIAGE.json and TRIAGE.md, Bash:
python3 .claude/skills/triage/scripts/checkpoint.py done ./.triage-state 6
Phase 0: Mode select and interview
0a. Parse arguments
From $ARGUMENTS: extract the findings path (first positional), --auto
flag, --votes N (default 3), --repo PATH (default .), --fp-rules FILE (default none). If no findings path was given, ask for one and stop.
If --fp-rules was given, Read the file now and carry its contents as
context.extra_fp_rules for injection into the Phase 3a verifier prompt.
0b. Interactive mode (default): interview the user
Unless --auto was passed, use AskUserQuestion to gather context that
shapes verification and ranking. Batch into one or two calls of up to four
questions. Expect free-text answers via "Other"; the multiple-choice options
are prompts, not constraints.
Round 1 (single AskUserQuestion call):
Environment & trust boundary (header
Environment, single-select)What kind of system are these findings from, and where does untrusted input enter it?Options:Internet-facing web service (HTTP is untrusted),Internal service (callers are authenticated peers),Library / SDK (caller is the trust boundary),CLI / batch tool (operator inputs trusted, file inputs not),Embedded / firmware (physical access in scope). Reachability is judged against this boundary; "command injection from env var" is a true positive in a multi-tenant web service and a rule-8 false positive in an operator CLI.Threat model (header
Threat model, multi-select)What does a worst-case attacker look like for this system, and what must never happen? Free text is best.Options:Unauthenticated remote code execution,Tenant-to-tenant data leakage,Privilege escalation to admin,Supply-chain compromise of downstream users,Denial of service against a paid SLA,Compliance-scoped data exposure (PII / PCI / PHI). Phase 4 boosts findings that map onto a stated threat.Scoring standard (header
Scoring, single-select)How should severity be expressed in the output?Options:Derived HIGH/MEDIUM/LOW from preconditions (default),CVSS v3.1 vector + base score,CVSS v4.0 vector + base score,OWASP Risk Rating (likelihood x impact),Organization bug-bar (describe in Other). The precondition rule is always computed; this controls whatseverity_labeladditionally shows.Noise tolerance (header
Noise tolerance, single-select)When verifiers disagree, which way should ties break?Options:Precision: drop anything not majority-confirmed (fewer FPs, may miss real bugs),Recall: keep split votes as needs_manual_test (more to review, fewer misses),Ask me per-finding when it happens.
Round 2 (conditional): if the threat-model answer was empty or generic,
or the scoring answer was Organization bug-bar, ask one targeted follow-up.
Record the answers as a context dict carried through every phase and
echoed in the output under triage_context.
0c. Auto mode defaults
When --auto is set, do not call AskUserQuestion. Use:
- Environment:
Unknown. Treat any externally-reachable entry point as untrusted; flag trust-boundary assumptions explicitly in rationale. - Threat model: empty (no boost).
- Scoring: derived HIGH/MEDIUM/LOW.
- Noise tolerance: precision.
0d. Threat-model ingest (both modes)
If {repo}/THREAT_MODEL.md exists (or the user points at one), Read it and
extract its three severity inputs:
context.purpose: 1-2 sentences from Section 1 — what the system is FOR and who uses it. Purpose governs severity: an outcome that is the system's job is not an impact — "open redirect" on a URL shortener, "executes user-supplied code" on a CI runner, "serves stranger-uploaded files" on a file-sharing host.context.assets: the Section-2 asset table, one bullet per row:"<asset> — <description> (sensitivity: <level>)".context.gating_questions: the Section-6 open-question bullets — these are the unresolved facts that gate severity (mounted secrets? auth in front? multi-tenant?).
Both are passed into the Phase-4a ranking prompt (ASSET INVENTORY and
SEVERITY-GATING QUESTIONS blocks). Section 3 entry points remain scoping
input only. If no THREAT_MODEL.md, set both to empty — the interview's
threat_model answers are separate and unaffected.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 0, "context": {mode, environment, purpose, threat_model, assets, gating_questions, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}
Then Bash:
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 0 interview --from ./.triage-state/_chunk.tmp
On resume past Phase 0, the interview is not re-asked; context is
restored from this file.
Phase 1: Ingest and normalize
Turn the input into a flat findings[] list with stable ids, regardless of
source format.
1a. Detect input shape
Inspect the findings path:
- Directory: Glob for
**/*.jsonand**/*.jsonl. Recognized containers, in priority order:VULN-FINDINGS.json(a{findings: [...]}container): read.findings[].reports/bug_*/report.jsonorreports/manifest.jsonl(an execution harness's pipeline output — e.g. the defending-code reference pipeline): one finding perbug_NN. Mapcrash.crash_type→category,verdict.severity_rating→severity, the prosereport→description, crash file from the ASAN top frame →file/line.found_bugs.jsonl: one finding per line.- Any other
*.jsonwhose top level is a list of objects, or an object with afindings/results/issues/vulnerabilitiesarray: that array.
- Single
.json/.jsonlfile: same recognition as above. - Markdown / text: split on level-2/3 headings or
---rules; for each section, extractfile,line,category,severity,descriptionby pattern (File:,Line:,Severity:labels orpath:NNspans). Best-effort; marksource_format: "markdown_heuristic".
If nothing parseable is found, stop and report what was seen.
1b. Normalize fields
For each raw record, build a finding dict. Pull what's present; never guess what's absent. Field map (source-key aliases → canonical):
| Canonical | Also accept |
|---|---|
file |
path, location.file, filename, ASAN top-frame file |
line |
line_number, location.line, lineno |
end_line |
line_end, location.end_line, endLine, line_range end |
source_ref |
source, taint_source, entry_point (as file:line) |
sink_ref |
sink, taint_sink, dangerous_call (as file:line) |
threat_ids |
threat_id, threats, rule_tags (list of ids) |
category |
type, cwe, rule_id, crash_type, vulnerability_class |
severity |
severity_rating, level, priority, risk |
title |
name, summary, message |
description |
details, report, body, evidence |
exploit_scenario |
attack_scenario, poc, reproduction |
preconditions |
requirements, assumptions |
recommendation |
fix, remediation, mitigation |
scanner_confidence |
confidence, score, certainty (normalize to 0.0-1.0) |
threat_ids is which threat-model rows the scanner's scope came from
(/vuln-scan stamps them). Carry it through; Phase 4's threat_match is
computed independently from the operator's own threat model, and the two
disagreeing is signal, not an error to reconcile at ingest.
source_ref / sink_ref are the scanner's data-flow evidence — the
file:line where untrusted input enters and the file:line where it is
used unsafely (/vuln-scan emits both; most third-party scanners emit
neither, and a taint-tracking one emits both). Ingest them verbatim.
Never synthesize a ref from the description, the file:line, or a
guess: dedup and verification below anchor on these, and a manufactured
ref asserts a flow nobody traced. A finding whose two refs are
equal is a context-free finding (hardcoded secret, weak constant), not a
malformed one. end_line likewise: absent means "the scanner named one
line", not "the region is one line" — the difference matters in 2a.
Attach to every finding:
id:f001,f002, ... in ingest order. Ifscanner_confidenceis present on most findings, order ingest by it descending so high-signal findings get verified (and surface in partial output) first; otherwise keep source order. This is a scheduling prior only — it does not affect verdicts.source: relative path of the file it came from, plus source format.missing_fields: list of canonical fields that were absent. Iffileis missing or does not resolve under--repo, the finding is unlocatable: it skips dedup and verification and is emitted directly withverdict: false_positive,verify_verdict: needs_manual_test,confidence: 0,refute_reasons: ["doesnt_exist"],rationale: "no source location in input; cannot verify statically; human review required". Never emit a confident verdict on a finding you could not locate, and never let it absorb or be absorbed by dedup.
1c. Locate the target codebase
Resolve --repo (default cwd). For the first 5 findings with a file,
check the path resolves under the repo. Try, in order: (a) repo/file
as-given; (b) file as an absolute or cwd-relative path; (c) repo/file
with common prefixes stripped from file (src/, app/, ./, or the
repo's own basename, e.g. myapp/server.py with --repo myapp).
Record which resolution worked, then apply it to every finding
individually. A finding whose path still resolves to nothing on disk is
unlocatable — exactly the 1b semantics (mechanical false_positive /
doesnt_exist / needs_manual_test, no verifier votes, excluded from
dedup), with the rationale naming the unresolvable path. One bad scanner
row must not cost verifier votes or stall the batch. If NO finding
resolves, stop:
tell the user verification needs source access and the cited files aren't
reachable, and suggest a --repo value based on the longest common suffix
you can see.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 1, "context": {...}, "findings": [ {normalized finding dicts with id/source/file/line/category/...} ], "path_resolution": "<which of a/b/c worked>"}
Then Bash:
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 1 ingest --from ./.triage-state/_chunk.tmp
Phase 2: Deduplicate (before verification)
Collapse repeats so duplicate findings don't each burn N verifiers.
2a. Deterministic pass (inline, no subagent)
Cluster findings that share both of:
- same
file(after path normalization), AND - same
category(case-insensitive, punctuation stripped),
and then meet any one of these three location tests:
- Line proximity —
linevalues within 10 of each other. Both-missing matches; one-side-missing does NOT (a line-less record must not absorb a located one). - Range overlap — the findings' line ranges intersect:
a.start <= b.end AND b.start <= a.end, where a finding's range is[line, end_line]andend_linefalls back tolinewhen absent. This catches the same region re-detected at different boundaries — a scanner reporting a whole vulnerable function as40-120and another reporting95-130inside it are one finding, though their start lines are 55 apart and rule 1 misses them. - Identical flow — both findings carry a
source_refAND asink_refand both refs match. Same entry, same sink, same category is one bug at any line distance; thelinea scanner chose to anchor on is arbitrary along a flow. This test alone is exempt from the same-filegate above: one scanner anchors a finding on its source file and another on its sink file, and matching refs pin the flow harder thanfiledoes.
Ref conflict blocks a deterministic collapse. If both findings carry a
sink_ref and the two differ, do NOT collapse them here even when rule 1
or 2 matches — hand the pair to 2b. Two distinct sinks a few lines apart in
one function are two fixes, and a ±10 window is wide enough to swallow the
second one silently. This is what makes any line window unsafe on its own;
the refs are what make the collision detectable without spending a model on
it.
Within each cluster, the canonical is the record with the fewest
missing_fields; ties break to lowest id. (Because source_ref /
sink_ref are canonical fields, this already prefers the record that
carries data-flow evidence.) Every other member gets verdict: duplicate,
duplicate_of: <canonical id>, and is removed from the working set. Record
duplicate ids on the canonical as absorbed: [...]; where the canonical
lacks a ref that an absorbed member carries, copy that ref onto the
canonical — the evidence survives the collapse even though the record does
not.
2b. Semantic pass (one subagent, only if >1 cluster survives)
Spawn ONE Task with subagent_type: "general-purpose" and this prompt:
You are deduplicating security findings before expensive verification. Two
findings are DUPLICATES if fixing one would also fix the other. Two findings
are DISTINCT if they have genuinely independent root causes, even if they
share a category or file.
Treat as DUPLICATE:
- Same root cause described with different wording or by different scanners
- A shared vulnerable helper function reported once per call site
- A missing global protection (auth check, output encoding) reported once
per endpoint that lacks it
- A cause ("missing input validation on `name`") and its consequence
("SQL injection via `name`") in the same code path
Treat as DISTINCT:
- Different categories in the same file region (an "ssrf" near a
"buffer_overflow" is not a duplicate just because the lines are close)
- Same file, same category, but different tainted variables reaching
different sinks
- Same helper, but two independent bugs inside it
- Two endpoints missing the same check, where the fix is per-endpoint
rather than a shared gate
Some findings carry data-flow evidence — `source -> sink`, each a
`file:line`. Where both findings have it, prefer it over the prose:
- Matching source AND sink is one flow: DUPLICATE even when the categories
are labelled differently (one scanner's "missing input validation" and
another's "sql injection" on that flow are cause and consequence).
- Matching sink, different sources: DUPLICATE only if one fix at the sink
closes both; if each source needs its own validation, they are DISTINCT.
- Different sinks: DISTINCT unless one shared helper feeds both.
- A finding whose last field is `(none traced)` has no such evidence —
judge it on prose alone, and do not read the absence as independence.
Below are the candidate findings (one per line: id | file:line | category |
title | source -> sink). Group them. Respond with ONLY lines of the form:
GROUP: <canonical_id> <- <dup_id>, <dup_id>, ...
One line per group that has duplicates. Omit singletons. Pick the most
specific / best-described finding as canonical. No prose.
CANDIDATES:
{one line per surviving finding: "f003 | src/auth.py:112 | sql_injection | User lookup concatenates name into query | src/api.py:40 -> src/auth.py:112"}
{findings without both refs end with "| (none traced)"}
Parse GROUP: lines. For each, mark the listed dup ids with
verdict: duplicate, duplicate_of: <canonical>, append them to the
canonical's absorbed, and drop them from the working set.
Carry forward candidates[] = the surviving canonicals. Report the split
in the terminal — "dedup: N deterministic, M semantic, K canonical" — so
a run that collapsed half its input on the ±10 window is visible as such,
not as a quiet drop in the verifier bill.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 2, "context": {...}, "findings": [ {all findings; duplicates carry verdict/duplicate_of} ], "candidates": ["f001", "f003", "..."]}
Then Bash:
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 2 dedup --from ./.triage-state/_chunk.tmp
Phase 3: Verify
For each candidate, N independent adversarial verifiers re-derive the claim from the code and vote. Each verifier's stance is "find any reason this is wrong." Each starts from the code at the cited location, not the scanner's description, and never sees the other verifiers' reasoning (shared context propagates blind spots).
3a. Verifier instructions
The full verifier instructions are the system prompt of the
triage-verifier agent definition (../../agents/triage-verifier.md
relative to this skill directory — same layout in the repo and in a plugin
install). As the agent's system prompt they sit in the prompt-cache prefix
every verifier in the batch shares; each spawn's prompt carries only the
per-finding block in 3b. The per-spawn tail template (context header +
finding block) lives in references/prompts.md § Verifier tail (Phase
3a).
Call-graph context (only when the target is indexed). If
<repo>/.codegraph/ exists, spare each verifier the from-scratch caller
hunt: per candidate, run
codegraph explore "<cited function, or file:line>" from inside the repo
(e.g. cd <repo> && codegraph explore "..."), trim to a compact excerpt —
direct callers/callees of the cited function and any entry-point paths,
≤ ~40 lines, no verbatim source bodies — and add it to that candidate's
tail as the optional CALL GRAPH CONTEXT block (template in
references/prompts.md). All N votes for a candidate share the same
excerpt. If there is no index, or the command fails, omit the block and
say nothing — never index the target yourself. The verifier is instructed
to treat the graph as a starting point and read the actual call sites;
this changes where it looks first, not what counts as evidence.
3b. Spawn N verifiers per candidate, all in one message
For each finding in candidates[], build N Task calls (N = --votes,
default 3) with subagent_type: "triage-verifier" (plugin installs:
defending-code:triage-verifier) and description: "verify {id} vote {k}/{N}". Fallback: if neither agent name resolves,
Read the agent definition file above and spawn general-purpose verifiers
with its body pasted above the per-spawn tail.
Always set subagent_type; never fork. Omitting subagent_type forks
the orchestrator, and a fork inherits the full conversation context: every
other finding's description, the scanner's prose, and any prior verifier
results. That defeats verifier independence and re-introduces the
inherited-framing failure mode this phase exists to prevent. Each verifier
must start with a fresh, empty context and receive only the verifier
instructions plus the single finding under review. The same applies to the
ranking subagents in 4a.
Each spawn's prompt is only the tail from references/prompts.md
§ Verifier tail (Phase 3a): the run-constant context header (REPO PATH,
ENVIRONMENT, org rules) plus the per-finding "FINDING UNDER REVIEW" block
and the vote number.
Put all verifier Task calls in a single assistant message so they run
concurrently. Do not set run_in_background; you need the final text, not
an async handle. If len(candidates) * N exceeds ~40, shard into
sequential batches of ~40, but keep each batch a single message.
Prompt size at scale — fallback path only. When the triage-verifier
agent type resolves, the ~1200-word instructions live in the shared cached
system prompt and per-spawn cost stays flat at any batch size — never
switch to the compact form there. On the general-purpose fallback, every
spawn re-pays the full instructions inline; when candidates * votes > ~50
on that path, use this compact form instead (same procedure and output
contract, prose stripped):
Adversarially verify ONE scanner finding. Default: scanner is WRONG.
Read-only access scoped to {REPO_PATH} ONLY. No exec, no network.
ENVIRONMENT: {context.environment}
Steps: (1) Read {file}:{line} yourself; don't trust the description.
(2) Trace callers backwards; quote the first call-site file:line.
(3) Hunt for protections: validation, escaping, type bounds, auth gates,
dead/test code. (4) Stress-test each protection on every path.
Exclusion rules (FALSE_POSITIVE if matched): 1 volumetric DoS;
2 test/dead/fixture code; 3 intended design; 4 memory-safety in safe
lang outside unsafe/FFI; 5 SSRF path-only; 6 LLM prompt input;
7 object-storage traversal; 8 trusted operator env/CLI inputs;
9 client code, server vuln class; 10 outdated deps; 11 weak random
non-security; 12 low-impact nuisance (log spoof, open redirect, regex
inject); 13 missing-hardening-only, no exploit path (reachability only —
reachable-but-gains-nothing is still TRUE; impact is ranked later); 14 XSS in
auto-escape framework w/o raw-HTML escape hatch; 15 unguessable
UUID/token flagged predictable; 16 theoretical-only race/TOCTOU.
{+ org rules from --fp-rules if any}
End with EXACTLY:
VERDICT: TRUE_POSITIVE | FALSE_POSITIVE | CANNOT_VERIFY
CONFIDENCE: <0-10>
REFUTE_REASON: <doesnt_exist|already_handled|implausible_trigger|
intentional_behavior|misread_code|duplicate|not_actionable|n/a>
EXCLUSION_RULE: <1-16, org rule, or none>
FIRST_LINK: <file:line or "none found">
RATIONALE: <2-5 sentences, file:line cited>
FINDING: {id} {file}:{line} {category} (claimed {severity})
{title}
{description}
Vote {k}/{N}. Independent; do not seek other votes.
Findings with a file but no line get one verifier vote regardless
of --votes (a file-level sweep is expensive and doesn't benefit from
voting).
If any Task call returns status: "async_launched" instead of the
verifier's text, the runtime backgrounded it (some runtimes do this
automatically for large parallel batches). Pick one recovery and use it for
the whole batch:
- If completion notifications arrive in your conversation: parse each
verifier's VERDICT block from its notification
resultas it lands. Do not end your turn until every vote is accounted for. - If notifications do not arrive: do not poll transcript files. Re-spawn
the missing verifiers in a fresh Task batch (smaller shard size, e.g.
- and use the synchronous results. The same recovery applies to the dedupe subagent in 2b and the ranking subagents in 4a.
3c. Tally votes
For each candidate, parse the trailing block from each of its N verifiers
(tolerate code fences and whitespace). If a verifier errored, timed out,
or produced no parseable VERDICT block, re-spawn it once. If the retry
also fails, count that vote as cannot_verify with confidence: 0 and
note "verifier_error" in refute_reasons. The remaining N-1 votes still
decide.
Build:
vote_breakdown:{"true_positive": x, "false_positive": y, "cannot_verify": z}confidence: mean CONFIDENCE across votes that agree with the majority, rounded to one decimal.exclusion_rule: the modal EXCLUSION_RULE among FALSE_POSITIVE votes, elsenull.refute_reasons: sorted unique REFUTE_REASON values from FALSE_POSITIVE votes.first_links: unique FIRST_LINK values across all votes (reachability audit trail).rationale: the RATIONALE from the highest-confidence vote on the winning side, verbatim.
Decide verdict:
- Majority TRUE_POSITIVE →
verdict: true_positive. Proceeds to Phase 4. - Majority FALSE_POSITIVE →
verdict: false_positive. Skips Phase 4. - No majority (tie, or majority CANNOT_VERIFY):
- Noise tolerance
precision→verdict: false_positive; append"(split vote, dropped under precision policy)"to rationale. - Noise tolerance
recall→verdict: true_positivewithverify_verdict: needs_manual_test. Proceeds to Phase 4. - Noise tolerance
ask→ collect all split findings and present them in one AskUserQuestion call at the end of Phase 3 (header: id + title, options: keep / drop), then apply the user's choices.
- Noise tolerance
Run-level sanity guard. After tallying, count verifier_error votes
across the whole batch. If they exceed a third of all votes cast, or any
sharded batch returned zero parseable verdicts, stop before Phase 4:
the verifier harness itself is failing (agent type not resolving, tool
permissions, wrong --repo), and verdicts produced under those conditions
are noise, not evidence. Report the error rate and the first raw failure
to the user instead of writing a TRIAGE.json where "everything errored"
silently reads as "everything was verified". A batch with a sub-threshold
error count proceeds, but carries the count into the summary as
verifier_errors — never launder an error into a false-positive.
Build confirmed[] = candidates with verdict == true_positive.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 3, "context": {...}, "findings": [ {all findings with verdict/vote_breakdown/confidence/refute_reasons/first_links/rationale/exclusion_rule} ], "confirmed": ["f001", "..."]}
Then Bash:
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 3 verify --from ./.triage-state/_chunk.tmp
This is the most expensive checkpoint. When len(candidates) * votes exceeds
~40 and verifier spawns are sharded into sequential batches, additionally
checkpoint per candidate as its votes are tallied:
- Write tool →
./.triage-state/_chunk.tmp= that finding's post-tally dict. - Bash:
python3 .claude/skills/triage/scripts/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp
On resume at phase_done == 2, the Phase-3 entry point reads
progress.json:shards_done (default [] — do not glob shard files on
disk; stale shards from a prior run may exist), loads the corresponding
shard_{id}.json files, and spawns verifiers only for candidates[] ids
from phase2.json that are NOT in shards_done. Once every candidate is in
shards_done, write the consolidated phase3.json checkpoint as above.
Phase 4: Rank by impact x exploitability (confirmed findings only)
Recompute severity as impact-on-a-named-asset times exploitability — what the attacker gains in this deployment, times how easily they reach it — rather than from the category name, and judge the scanner's claimed severity separately. Verification and severity are independent judgments; "this is real" must not inflate into "this is critical," and easy reach must not inflate an empty asset into a HIGH.
4a. Ranking spawn
Spawn one Task per confirmed finding (subagent_type: "triage-ranker";
plugin installs: defending-code:triage-ranker; all in one message). The
full ranking instructions are that agent definition's cached system prompt;
each spawn's prompt is only the tail in references/prompts.md § Ranker
tail (Phase 4a) — {REPO_PATH}, the {context.*} values, and the
per-finding fields. Fallback: if neither agent name resolves, Read
../../agents/triage-ranker.md (relative to this skill directory) and
spawn general-purpose rankers with its body pasted above the tail.
4b. Merge
For each confirmed finding, parse the block and attach preconditions
(replacing any scanner-supplied list), access_level, asset, impact,
exploitability, severity (recomputed), severity_label,
deployment_condition (null if "none"), threat_match,
severity_alignment, verify_verdict, and append RANK_RATIONALE to
rationale (separated by a blank line from the Phase-3 rationale).
For findings that did NOT reach Phase 4 (false_positive, duplicate,
unlocatable): set severity: null, verify_verdict: null,
severity_alignment: null, preconditions: [], asset: null,
impact: null, exploitability: null, deployment_condition: null.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 4, "context": {...}, "findings": [ {all findings with severity/severity_label/preconditions/access_level/threat_match/severity_alignment/verify_verdict} ]}
Then Bash:
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 4 rank --from ./.triage-state/_chunk.tmp
Phase 5: Route
Tag each confirmed true-positive with the most specific component or owner
inferable. For each finding in confirmed[], stop at the first hit:
- CODEOWNERS / OWNERS. Grep
--repoforCODEOWNERS,OWNERS,.github/CODEOWNERS,docs/CODEOWNERS. If found, match the finding'sfileagainst its patterns (last match wins). Hint:"CODEOWNERS: <pattern> -> <owner(s)>". - git log. If
--repois a git checkout, rungit -C {REPO} log --format='%an' -n 50 -- "{file}"and tally the author lines yourself — no shell pipeline (sort/uniq/headare not whitelisted). Hint:"top committer: <name> (<n>/<total> recent commits); no CODEOWNERS entry". - Module fallback. Hint:
"component: <top-level dir of file>/; no CODEOWNERS or git history".
Attach as owner_hint. State the source so confidence is clear; a bare
username is less useful than "component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)". For non-true-positive findings,
set owner_hint: null.
Checkpoint: Write tool → ./.triage-state/_chunk.tmp:
{"phase": 5, "context": {...}, "findings": [ {all findings with owner_hint} ]}
Then Bash:
python3 .claude/skills/triage/scripts/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmp
Phase 6: Output
6a. Sort
Order all findings by:
verdict:true_positive, thenduplicate, thenfalse_positive.- Within true positives:
severityHIGH > MEDIUM > LOW, thenconfidencedescending, thenseverity_alignmentdescending. - Within others: original
id.
6b. Write ./TRIAGE.json
{
"triage_completed": true,
"triage_context": {
"mode": "interactive|auto",
"environment": "...",
"threat_model": ["..."],
"scoring": "...",
"noise_tolerance": "...",
"votes_per_finding": 3,
"repo": "..."
},
"summary": {
"input_count": 0,
"duplicates": 0,
"false_positives": 0,
"true_positives": 0,
"needs_manual_test": 0,
"verifier_errors": 0,
"by_severity": {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
},
"findings": [
{
"id": "f001",
"source": "VULN-FINDINGS.json#0",
"title": "...",
"file": "...",
"line": 0,
"end_line": null,
"source_ref": "...|null",
"sink_ref": "...|null",
"threat_ids": [],
"category": "...",
"claimed_severity": "HIGH",
"verdict": "true_positive|false_positive|duplicate",
"verify_verdict": "exploitable|mitigated|needs_manual_test|reachable_no_impact|null",
"confidence": 0.0,
"severity": "HIGH|MEDIUM|LOW|null",
"severity_label": "...",
"severity_alignment": 0,
"preconditions": ["..."],
"access_level": "...",
"asset": "...|null",
"impact": "HIGH|MEDIUM|NONE_LOW|null",
"exploitability": "HIGH|MEDIUM|LOW|null",
"deployment_condition": "...|null",
"threat_match": "...|null",
"rationale": "file:line-cited prose: reachability, protections, why each held or didn't; then ranking rationale",
"vote_breakdown": {"true_positive": 0, "false_positive": 0, "cannot_verify": 0},
"refute_reasons": ["..."],
"exclusion_rule": null,
"first_links": ["file:line", "..."],
"duplicate_of": null,
"absorbed": ["..."],
"owner_hint": "...",
"missing_fields": ["..."]
}
]
}
Every input finding appears exactly once (duplicates reference their
canonical via duplicate_of). Do not silently drop anything. Do not print
this JSON to the terminal; write to file only.
6c. Write ./TRIAGE.md
Reviewer-facing report. Build it incrementally. Do NOT emit the whole file in one Write. One chunk per finding; a stalled chunk loses that one section, not the file.
Step 1 — header. Write tool → ./TRIAGE.md (clobbers any prior file)
containing only the title block, summary, and ## Act on these heading:
# Triage Report
{summary line: N in -> D duplicates, F false positives, T confirmed (H high / M med / L low), X need manual test{if verifier_errors: , E verifier-error votes — see flagged findings}}
Context: {mode}; environment = {environment}; scoring = {scoring}; {votes}-vote verification.
## Act on these
Step 2 — per finding. For each true_positive in severity order:
- Write tool →
./.triage-state/_chunk.tmpcontaining ONE finding's section:
### [{severity}] {title} ({id})
`{file}:{line}` | {category} | claimed {claimed_severity} (alignment {severity_alignment:+d}) | confidence {confidence}/10
**Owner:** {owner_hint}
**Verdict:** {verify_verdict}, votes {vote_breakdown}
**Asset:** {asset} — impact {impact} x exploitability {exploitability}
**Moves if:** {deployment_condition or "nothing — severity is unconditional"}
**Preconditions ({n}):** {bulleted}
**Threat-model match:** {threat_match or "none"}
**Why:** {rationale}
**Reachability evidence:** {first_links}
{if source_ref or sink_ref:}**Claimed flow:** {source_ref or "?"} -> {sink_ref or "?"} (scanner-asserted; the verifier's reachability evidence above is what was read)
{if verify_verdict == needs_manual_test:}
> Recommend a human build a PoC; static reasoning hit its limit.
{if "verifier_error" in refute_reasons:}
> {n} of this finding's votes were verifier ERRORS, not verdicts — the
> remaining votes decided. Weigh accordingly.
- Bash:
python3 .claude/skills/triage/scripts/checkpoint.py append ./TRIAGE.md --from ./.triage-state/_chunk.tmp
Repeat for each true_positive.
Step 3 — footer. Write tool → ./.triage-state/_chunk.tmp containing the
Dropped table, then checkpoint.py append it the same way:
## Dropped
| id | title | file:line | why dropped |
{false_positives: refute_reasons + exclusion_rule}
{duplicates: "duplicate of {duplicate_of}"}
{unlocatable: "no source location in input"}
Checkpoint (final): Bash: `python3 .claude/skills/triage/scripts/checkpoint.py
…(truncated)