agentsclimarketplace

Triage

Skill air-gapped/skills/.claude/skills/triage

Claude Code plugin marketplace — 58 installable reference skills across vLLM/SGLang inference, Kubernetes & Harvester, GPU host bring-up, observability, security, and agent workflows.

Install
npx -y skills add air-gapped/skills --skill triage

Assembled from the repository path, not quoted from the project. Check it against their README if it does not work.

One thing to look at

  • 3 stars3 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.

What its author says it does

Copied from the file, not written here

[3/4 defending-code] Triage a batch of raw security findings. Third step of the find-and-fix loop (/threat-model -> /vuln-scan -> /triage -> /patch); consumes /vuln-scan's VULN-FINDINGS.json, but accepts any scanner output, so it also stands alone on a third-party backlog. Verify each is real, collapse duplicates, re-rank by impact-on-asset x exploitability, and tag with an owner. Takes a directory or file of scanner output and writes TRIAGE.json + TRIAGE.md sorted by what actually needs engineering attention. Use when asked to "triage findings", "validate scanner output", "prioritize vulns", or "review the backlog". Runs interactively by default; pass --auto to skip the interview.

SKILL.md

37.5 KB, ~9.6k tokens by cl100k_base, as published. Nobody here has run it

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 pipeline results/<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, 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; include the constraint in every 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.jsonsingle source of truth for resume position: {"status": "running"|"complete", "phase_done": N, "shards_done": [...]}. Resume decisions read ONLY this file, never a glob of phase*.json or 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 every save/shard/append call.

Start of run — resume check. Bash: python3 .claude/skills/triage/scripts/checkpoint.py load ./.triage-state

  • status == "absent" OR "complete", OR --fresh in $ARGUMENTSfresh start. Bash: python3 .claude/skills/triage/scripts/checkpoint.py reset ./.triage-state, then proceed to Phase 0.
  • status == "running" with phase_done == Nresume. Read ./.triage-state/phase0.json through phaseN.json in order (and any shard_*.json files listed in shards_done), merging keys into working state (later files override earlier — checkpoints may be deltas). Print Resuming from checkpoint: Phase N complete (./.triage-state/phaseN.json), and skip directly to Phase N+1.

End of every phase N. Two tool calls:

  1. Write tool → ./.triage-state/_chunk.tmp containing the phase's output JSON (schema at the tail of each phase section).
  2. 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):

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

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

  3. 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 what severity_label additionally shows.

  4. 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 **/*.json and **/*.jsonl. Recognized containers, in priority order:
    • VULN-FINDINGS.json (a {findings: [...]} container): read .findings[].
    • reports/bug_*/report.json or reports/manifest.jsonl (an execution harness's pipeline output — e.g. the defending-code reference pipeline): one finding per bug_NN. Map crash.crash_typecategory, verdict.severity_ratingseverity, the prose reportdescription, crash file from the ASAN top frame → file/line.
    • found_bugs.jsonl: one finding per line.
    • Any other *.json whose top level is a list of objects, or an object with a findings/results/issues/vulnerabilities array: that array.
  • Single .json / .jsonl file: same recognition as above.
  • Markdown / text: split on level-2/3 headings or --- rules; for each section, extract file, line, category, severity, description by pattern (File:, Line:, Severity: labels or path:NN spans). Best-effort; mark source_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):

CanonicalAlso accept
filepath, location.file, filename, ASAN top-frame file
lineline_number, location.line, lineno
categorytype, cwe, rule_id, crash_type, vulnerability_class
severityseverity_rating, level, priority, risk
titlename, summary, message
descriptiondetails, report, body, evidence
exploit_scenarioattack_scenario, poc, reproduction
preconditionsrequirements, assumptions
recommendationfix, remediation, mitigation
scanner_confidenceconfidence, score, certainty (normalize to 0.0-1.0)

Attach to every finding:

  • id: f001, f002, ... in ingest order. If scanner_confidence is 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. If file is missing or does not resolve under --repo, the finding is unlocatable: it skips dedup and verification and is emitted directly with verdict: 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 and apply it to every finding. If none resolve, 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 where all of:

  • same file (after path normalization), AND
  • same category (case-insensitive, punctuation stripped), AND
  • line numbers within 10 of each other. Both-missing matches; one-side- missing does NOT (a line-less record must not absorb a located one).

Within each cluster, the canonical is the record with the fewest missing_fields; ties break to lowest id. 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: [...].

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

Below are the candidate findings (one per line: id | file:line | category |
title). 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"}

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.

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 prompt (assemble once, reuse for every spawn)

The full verifier prompt lives in references/prompts.md § Verifier prompt (Phase 3a). Read it once at the start of Phase 3 and reuse it verbatim, substituting {REPO_PATH} and {context.environment} (and appending context.extra_fp_rules under an "ORG-SPECIFIC RULES:" heading if set). The per-finding "FINDING UNDER REVIEW" block in 3b is appended to it before each spawn.

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: "general-purpose" and description: "verify {id} vote {k}/{N}".

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 3a prompt plus the single finding under review. The same applies to the ranking subagents in 4a.

Each prompt is the verifier prompt from 3a with this block appended:

────────────────────────────────────────────────────────────────────────
FINDING UNDER REVIEW (from the scanner; treat as a CLAIM, not a fact):

  id:        {id}
  file:      {file}
  line:      {line}
  category:  {category}
  severity (claimed): {severity}
  title:     {title}

  description:
  {description}

  exploit_scenario:
  {exploit_scenario or "(not provided)"}

  preconditions (claimed):
  {preconditions as bullets or "(not provided)"}

You are vote {k} of {N}. You have NOT seen the other verifiers' reasoning
and you must NOT try to find it. Work independently from the code.

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. The 3a prompt is ~1200 words. When candidates * votes > ~50, 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 result as 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. 10) 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, else null.
  • 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 precisionverdict: false_positive; append "(split vote, dropped under precision policy)" to rationale.
    • Noise tolerance recallverdict: true_positive with verify_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.

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:

  1. Write tool → ./.triage-state/_chunk.tmp = that finding's post-tally dict.
  2. 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 prompt

Spawn one Task per confirmed finding (subagent_type: "general-purpose", all in one message). The full ranking prompt lives in references/prompts.md § Ranking prompt (Phase 4a). Read it at the start of Phase 4 and use it verbatim, substituting {REPO_PATH}, the {context.*} values, and the per-finding fields.

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:

  1. CODEOWNERS / OWNERS. Grep --repo for CODEOWNERS, OWNERS, .github/CODEOWNERS, docs/CODEOWNERS. If found, match the finding's file against its patterns (last match wins). Hint: "CODEOWNERS: <pattern> -> <owner(s)>".
  2. git log. If --repo is a git checkout, run git -C {REPO} log --format='%an' -n 50 -- "{file}" and tally the author lines yourself — no shell pipeline (sort/uniq/head are not whitelisted). Hint: "top committer: <name> (<n>/<total> recent commits); no CODEOWNERS entry".
  3. 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:

  1. verdict: true_positive, then duplicate, then false_positive.
  2. Within true positives: severity HIGH > MEDIUM > LOW, then confidence descending, then severity_alignment descending.
  3. 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,
    "by_severity": {"HIGH": 0, "MEDIUM": 0, "LOW": 0}
  },
  "findings": [
    {
      "id": "f001",
      "source": "VULN-FINDINGS.json#0",
      "title": "...",
      "file": "...",
      "line": 0,
      "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}

Context: {mode}; environment = {environment}; scoring = {scoring}; {votes}-vote verification.

## Act on these

Step 2 — per finding. For each true_positive in severity order:

  1. Write tool → ./.triage-state/_chunk.tmp containing 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 verify_verdict == needs_manual_test:}
> Recommend a human build a PoC; static reasoning hit its limit.
  1. 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 done ./.triage-state 6 The next invocation's resume check sees status == "complete" and starts fresh.

6d. Terminal summary

Under ~12 lines:

Triage complete: {N} findings -> {T} confirmed, {F} false positives, {D} duplicates.

  HIGH:   {n}   {title of top HIGH, owner_hint}
  MEDIUM: {n}
  LOW:    {n}
  Needs manual test: {n}

  Top refute reasons: {top 3 refute_reasons with counts}

Wrote ./TRIAGE.md and ./TRIAGE.json

Next step: > /patch ./TRIAGE.json --repo {repo}

Emit the Next step line only when at least one finding survived as a true positive; with zero confirmed findings there is nothing to patch, so say that instead. /patch writes inert diffs to ./PATCHES/ — it never applies them.


Testing this skill

A five-finding fixture ships at fixtures/canary-findings.json (2 real, 1 dup, 2 FP). Its findings cite targets/canary/entry.c from the defending-code reference harness (see ../vuln-scan/HARNESS.md); to run the smoke test, clone that harness and point --repo at it:

/triage <skill-dir>/fixtures/canary-findings.json --auto --repo <harness>/targets/canary

Expected: f001 and f003 confirmed; f002 duplicate of f001; f004 dropped (misread_code: it's a read buffer, not a randomness source); f005 dropped (already_handled: there is a null check at line 68). Without the source tree the verifiers cannot read the cited code, so they return needs_manual_test — the fixture then documents the ingest/dedup shape rather than exercising verification.

Against any real scanner output, hand-check a sample of TRUE_POSITIVE/HIGH results (the first_links should point at real call sites) and a sample of FALSE_POSITIVE rejects (the exclusion_rule or refute_reasons should be defensible).


Design notes

  • Checkpoints are per-phase JSON, not conversation state. A CLI --resume restores transcript history but doesn't help when the orchestrator's context window itself fills; file-backed checkpoints let a brand-new session pick up from the last completed phase. ./.triage-state/ is scratch — add to .gitignore.
  • Dedupe runs before verify to cut verifier spend by the duplication factor (often 2-4x on multi-scanner input) at the cost of one cheap subagent.
  • Semantic dedupe is one agent, given only id/file/line/category/title: enough to cluster, not enough to leak one scanner's reasoning into another finding's verification.
  • Bash is allowed narrowly for git log (owner hints), jq/find (ingest), and python3 .claude/skills/triage/scripts/checkpoint.py (state I/O). The actual safety property is "no execution of target code," which is preserved.
  • CANNOT_VERIFY exists so verifiers aren't forced into a false binary. It maps to needs_manual_test under recall policy and to a drop under precision policy.
  • Threat-model boost is capped at one step — and gated on the asset actually existing — so a stated threat can't re-inflate a LOW back to HIGH and defeat the impact x exploitability rule.
  • severity_label is separate from severity. Sorting always uses the impact x exploitability HIGH/MEDIUM/LOW; the label is presentation-layer for whatever standard the reviewer's tooling expects.
  • Pipeline report.json ingest is best-effort. Those reports describe ASAN crashes with prose exploitability analysis rather than the file/line/category shape static verifiers expect. Expect more needs_manual_test verdicts on that input than on static-scanner JSON.
  • Sharding at ~40 parallel Tasks is a conservative ceiling for typical agent-spawn limits; tune up if your runtime allows.
  • No network, deliberately. CVE-database enrichment and upstream-fix checks would help ranking but break the air-gapped-review property.

Provenance

Adapted (Apache-2.0) from the triage skill in anthropics/defending-code-reference-harness. Class-agnostic: the verifier exclusion rules and impact x exploitability severity apply to web, cloud, crypto, and memory-safety findings alike. See ../vuln-scan/HARNESS.md for the autonomous pipeline whose output this skill can ingest.

What ships with it: 6 files

39.2 KB alongside SKILL.md, 1 of them executable

fixtures/

references/

scripts/

Gives 0 of the 12 instructions most debug triage skills give in ~9.6k tokens

Counted across 839 of the 1,149 authors here whose files we hold, read 2026-08-07

  • Investigate root cause before proposing any fixin 102 of 839, across 67 files
  • Read error messages completelyin 89 of 839, across 49 files
  • Create a failing test case before fixingin 84 of 839, across 46 files
  • Reproduce the issue consistentlyin 82 of 839, across 41 files
  • Change one variable at a timein 82 of 839, across 42 files
  • Check recent changesin 74 of 839, across 36 files
  • Write the regression test before fixingin 74 of 839, across 40 files
  • Fix the root cause not the symptomin 60 of 839, across 45 files
  • Implement a single fix at a timein 59 of 839, across 20 files
  • Trace data flow backward to the sourcein 50 of 839, across 20 files
  • Remove all debug instrumentationin 49 of 839, across 13 files
  • Form a single hypothesisin 48 of 839, across 18 files

Said here and by no other author read

  • verify each finding by reading source code
  • deduplicate findings across runs and scanners
  • rank findings by attacker impact and exploitability
  • execute the triage process in distinct phases
  • save state to a checkpoint file after every phase
  • merge checkpoint files in numerical order when resuming

Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.

Keep looking

Skills are one crate of 328,083. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.