- name
- triage
- description
- Triage a batch of raw security findings. Verify each is real, collapse duplicates, re-rank by derived 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.
- argument-hint
- <findings-path> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE] [--fresh]
- allowed-tools
- ["Read","Glob","Grep","Write","Task","AskUserQuestion","Bash(git log:*)","Bash(jq:*)","Bash(find:*)","Bash(ls:*)","Bash(wc:*)","Bash(python3 .claude/skills/_lib/checkpoint.py:*)"]
# triage
Adversarial triage of raw security-scanner output. Does four jobs:
**verify** each finding is real, **deduplicate** across runs and scanners,
**rank** survivors by derived exploitability rather than 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`, an `INCIDENTS.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/_lib/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/_lib/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 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/_lib/checkpoint.py load ./.triage-state`
- `status == "absent"` OR `"complete"`, OR `--fresh` in `$ARGUMENTS` →
**fresh start.** Bash:
`python3 .claude/skills/_lib/checkpoint.py reset ./.triage-state`,
then proceed to Phase 0.
- `status == "running"` with `phase_done == N` → **resume.** 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/_lib/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/_lib/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.
**Checkpoint:** Write tool → `./.triage-state/_chunk.tmp`:
```json
{"phase": 0, "context": {mode, environment, threat_model, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}
```
Then Bash:
`python3 .claude/skills/_lib/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[]`.
- `INCIDENTS.json` (the `/dnr-hunt` / `/dnr-respond` output): read
`.incidents[]`; each incident is one finding. Map the nested fields —
`vuln.file` → `file`, `vuln.line` → `line`, `vuln.type` → `category`,
`vuln.function` → `function`, `description` ← `vuln.mechanism` plus
`impact` — and keep `id`, `title`, `verdict`, and `poc`; the 1b
aliases route `poc` → `exploit_scenario`, and a `confirmed_exploited`
verdict means the incident arrived execution-verified, not statically
guessed — verifiers should weight that. The top-level `ruled_out`
array is not findings — don't ingest it. Incidents with no
`vuln.file` follow the 1b missing-`file` rule like any other finding.
- `reports/bug_*/report.json` or `reports/manifest.jsonl` (this repo's
pipeline output): one finding per `bug_NN`. Map `crash.crash_type` →
`category`, `verdict.severity_rating` → `severity`, the prose `report` →
`description`, 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):
| Canonical | Also accept |
|-----------------|----------------------------------------------------------|
| `file` | `path`, `location.file`, `filename`, ASAN top-frame file |
| `line` | `line_number`, `location.line`, `lineno` |
| `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) |
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. `harness/grade.py` with `--repo harness`).
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`:
```json
View on GitHub