triage について
triageは、ソースコードを参照してスキャナーの検出結果を評価するセキュリティスキルです。検出結果の検証と重複排除を行い、悪用可能性で順位付けして担当者に割り当てます。
主な機能
- 01ソースコードに基づく検出結果の検証
- 02複数スキャンやスキャナー間の重複排除
- 03推定した悪用可能性による順位付け
- 04検出結果をコンポーネント担当者に割り当て
- 05対話モードと自動モードに対応
活用シーン
- 01未整理のセキュリティスキャン結果を確認
- 02複数回のスキャン結果を優先順位付け
- 03担当者付きの修正リストを作成
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ anthropics/ defending-code-reference-harness --skill triageインストール後、Claude Code で/triageを入力して読み込まれたか確認します。
同梱ファイルfixtures/canary-findings.jsonREADME.md
詳細
トリアージ
セキュリティスキャナーの生出力に対する敵対的トリアージです。次の4つの作業を行います。 各検出結果が実際の問題かどうかを検証し、実行やスキャナーをまたいで 重複を排除し、スキャナーが主張する重大度ではなく、導出した悪用可能性に基づいて 残った結果を順位付けし、それぞれをコンポーネントの担当者に割り当てます。生の出力ではなく、 順位と担当者を付けた簡潔なリストを出力します。
/triage <findings-path> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE] で実行します。
引数($ARGUMENTSから解析します。位置引数の$1/$2展開は
ランタイム間で安定していません):
- findings path(最初の位置引数、必須):JSONファイル、JSONファイルのディレクトリ、
VULN-FINDINGS.json、INCIDENTS.json、パイプラインのresults/<target>/<ts>/ディレクトリ、 またはMarkdownレポート。 --auto:インタビューを省略し、デフォルト値を使います。デフォルトモードは 対話形式です。--votes N:検出結果ごとの検証者の投票数(デフォルトは3。簡易確認には1、 重大な影響を伴う一括処理には5を使います)。--repo PATH:対象コードベースへの読み取り専用パス(デフォルトはcwd)。 検証にはソースへのアクセスが必要です。引用されたファイルにアクセスできない場合、 スキルはエラーで停止します。--fp-rules FILE:FILEの内容を検証者の除外ルール一覧(フェーズ3a)に追加します。 組織固有の先例に使用します:「すべての箇所でPrisma ORMを使用しているため、SQLiは 生のクエリに限る」「k8sのリソース制限でDoSを防止する」など。プレーンテキストで、 1行または1段落につき1つのルールを記述します。--fresh:./.triage-state/にある既存のチェックポイントを無視し、フェーズ0から開始します。 このフラグがない場合、チェックポイントが存在すれば、スキルは最後に完了したフェーズから再開します。
**ツール:**Read、Glob、Grep、Write、Task、AskUserQuestion。Bashの使用は、
git、find、wc、ls、jq、および
python3 .claude/skills/_lib/checkpoint.py(チェックポイントのI/O)のみに限ります。
**対象コードを実行しないでください。**ビルド、実行、依存関係のインストール、 リクエストの送信は禁止です。誤って実際の対象に対して動作する概念実証は許容できません。また、「動作する PoCを書けなかった」ことは、悪用できないとする弱い証拠です。すべての結論は ソースを読むことで導きます。この制約はオーケストレーターとすべてのサブエージェントに適用されます。 すべてのTaskプロンプトにこの制約を含めてください。確度の高いHIGHの検出結果については、 フォローアップとして人手で作成するPoCを推奨してください。
**ネットワークに接続しないでください。**パッケージレジストリの参照、CVEデータベースへの照会、 上流コミットの取得は禁止です。
チェックポイント処理(Phase 0 の前と各フェーズの後に実行)
大量の検出結果を処理する場合、実行全体の途中でコンテキストを使い切ったり、レート制限に達したりすることがあります。特に Phase 3 では、candidates × votes 個の検証器が起動します。フェーズの状態は ./.triage-state/ に保持されるため、新しい /triage セッションを開始すれば、インタビューをやり直したり、検証器を再起動したりせずに再開できます。
チェックポイントの I/O はすべて python3 .claude/skills/_lib/checkpoint.py を通して行います(アトミック書き込み、JSON 検証済み)。progress.json を Write ツールで直接書き込まないでください。ペイロードをヒアドキュメントや標準入力経由で渡さないでください。ターゲット由来の文字列がヒアドキュメントの区切り文字と衝突し、シェルから抜け出すおそれがあります。Write→--from パターンを使うことで、リポジトリ由来のバイト列を Bash の引数に含めずに済みます。
./.triage-state/ 内の状態ファイル:
progress.json— 再開位置の唯一の信頼できる情報源:{"status": "running"|"complete", "phase_done": N, "shards_done": [...]}. 再開の判断では、このファイルのみを読み込みます。phase*.jsonやシャードファイルの glob は使用しません(以前の実行で残った古いファイルを信頼してはいけません)。phaseN.json— Phase N のデータペイロード(スキーマは以下の各フェーズセクションの末尾を参照してください)。_chunk.tmp— 一時的なペイロードバッファ。各save/shard/append呼び出しの前に上書きされます。
実行開始時 — 再開の確認。 Bash:
python3 .claude/skills/_lib/checkpoint.py load ./.triage-state
status == "absent"または"complete"、あるいは$ARGUMENTSに--freshがある → 新規に開始します。 Bash:python3 .claude/skills/_lib/checkpoint.py reset ./.triage-stateを実行してから、Phase 0 に進みます。status == "running"かつphase_done == N→ 再開します。./.triage-state/phase0.jsonからphaseN.jsonまでを順番に読み込み(およびshards_doneに記載されたshard_*.jsonファイルも読み込み)、キーを作業状態にマージします(後のファイルが前のファイルを上書きします。チェックポイントは差分の場合があります)。Resuming from checkpoint: Phase N complete (./.triage-state/phaseN.json)と表示して、Phase N+1 へ直接進みます。
各 Phase N の終了時。 ツール呼び出しは 2 回です。
- Write ツール → フェーズの出力 JSON を
./.triage-state/_chunk.tmpに書き込みます(スキーマは各フェーズセクションの末尾を参照してください)。 - Bash →
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state <N> <name> --from ./.triage-state/_chunk.tmp
実行終了時。 TRIAGE.json と TRIAGE.md を書き込んだ後、Bash:
python3 .claude/skills/_lib/checkpoint.py done ./.triage-state 6
Phase 0:モード選択とインタビュー
0a. 引数の解析
$ARGUMENTS から、検出結果のパス(最初の位置引数)、--auto フラグ、--votes N(既定値は 3)、--repo PATH(既定値は .)、--fp-rules FILE(既定値はなし)を取り出します。検出結果のパスが指定されていない場合は、パスを尋ねて終了します。--fp-rules が指定されている場合は、今すぐファイルを読み込み、その内容を context.extra_fp_rules として保持し、Phase 3a の検証器プロンプトに挿入します。
0b. 対話モード(デフォルト):ユーザーに質問する
--auto が渡されていない場合は、AskUserQuestion を使って、検証と順位付けに影響するコンテキストを収集します。質問は最大4問ずつ、1回または2回にまとめてください。「Other」で自由記述の回答を受け付けます。選択肢は質問の参考であり、回答を制限するものではありません。
ラウンド1(AskUserQuestionを1回呼び出す):
-
環境と信頼境界(ヘッダー
Environment、単一選択)これらの調査結果はどのようなシステムに関するもので、信頼できない入力はどこから入りますか?選択肢:インターネットに公開されたWebサービス(HTTPは信頼できない),内部サービス(呼び出し元は認証済みのピア),ライブラリ/SDK(呼び出し元が信頼境界),CLI/バッチツール(オペレーターの入力は信頼できるが、ファイル入力は信頼できない),組み込み/ファームウェア(物理アクセスを対象範囲に含める)。 到達可能性はこの境界を基準に判断します。「環境変数によるコマンドインジェクション」は、マルチテナントのWebサービスでは真陽性ですが、オペレーターが使うCLIではルール8に該当する偽陽性です。 -
脅威モデル(ヘッダー
Threat model、複数選択)このシステムに対する最悪の攻撃者はどのような存在で、何が絶対に起きてはなりませんか?自由記述が最適です。選択肢:認証なしのリモートコード実行,テナント間のデータ漏えい,管理者への権限昇格,下流ユーザーに対するサプライチェーン侵害,有料SLAに対するサービス拒否,コンプライアンス対象データの漏えい(PII/PCI/PHI)。 フェーズ4では、明示された脅威に該当する調査結果の優先度を上げます。 -
スコアリング基準(ヘッダー
Scoring、単一選択)出力では重大度をどのように表すべきですか?選択肢:前提条件から導出するHIGH/MEDIUM/LOW(デフォルト),CVSS v3.1のベクターと基本スコア,CVSS v4.0のベクターと基本スコア,OWASPリスク評価(可能性 × 影響),組織のバグ基準(「Other」に説明を記入)。 前提条件のルールは常に計算されます。この設定は、severity_labelに追加で表示する内容を指定します。 -
ノイズ許容度(ヘッダー
Noise tolerance、単一選択)検証者の判断が分かれた場合、どちらを優先しますか?選択肢:適合率:過半数の確認が取れないものは除外する(偽陽性は減るが、実際のバグを見逃す可能性がある),再現率:票が分かれたものはneeds_manual_testとして残す(確認対象は増えるが、見逃しは減る),該当する場合は調査結果ごとに確認する。
ラウンド2(条件付き):脅威モデルの回答が空欄または一般的な内容だった場合、またはスコアリングの回答が Organization bug-bar だった場合は、対象を絞った追加質問を1つします。
回答はすべてのフェーズで引き継ぐ context 辞書に記録し、出力の triage_context にも含めます。
0c. 自動モードのデフォルト
--auto が設定されている場合は、AskUserQuestionを呼び出しません。次を使用します。
- 環境:
不明。外部から到達可能なすべてのエントリーポイントを信頼できないものとして扱い、信頼境界に関する前提を理由に明記する。 - 脅威モデル:空欄(優先度の加算なし)。
- スコアリング:導出したHIGH/MEDIUM/LOW。
- ノイズ許容度:適合率。
チェックポイント: ツールへの書き込み → ./.triage-state/_chunk.tmp:
{"phase": 0, "context": {mode, environment, threat_model, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}
次にBashを実行:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 0 interview --from ./.triage-state/_chunk.tmp
フェーズ0を過ぎた状態から再開する場合、インタビューは再実施されません。このファイルから context が復元されます。
フェーズ1:取り込みと正規化
入力元の形式にかかわらず、入力を安定したIDを持つフラットな findings[] リストに変換します。
1a. 入力形式の検出
検出結果のパスを調べます。
- ディレクトリ:
**/*.jsonと**/*.jsonlを Glob で検索します。認識するコンテナは、優先順位の高い順に次のとおりです。VULN-FINDINGS.json({findings: [...]}コンテナ):.findings[]を読み取ります。INCIDENTS.json(/dnr-hunt//dnr-respondの出力):.incidents[]を読み取ります。各インシデントが1件の検出結果です。ネストされたフィールドをマッピングします。vuln.file→file、vuln.line→line、vuln.type→category、vuln.function→function、description←vuln.mechanismとimpact。また、id、title、verdict、pocは保持します。1b の エイリアスによってpocはexploit_scenarioに割り当てられます。また、confirmed_exploitedの判定は、そのインシデントが静的解析による推測ではなく、 実行によって検証済みであることを意味します。検証者はその点を重視してください。 トップレベルのruled_out配列は検出結果ではないため、取り込まないでください。vuln.fileがないインシデントには、他の検出結果と同様に、1b のfile欠落時の ルールを適用します。reports/bug_*/report.jsonまたはreports/manifest.jsonl(このリポジトリのパイプライン出力):bug_NNごとに1件の検出結果です。crash.crash_type→category、verdict.severity_rating→severity、文章形式のreport→description、ASAN のトップフレームにあるクラッシュファイル →file/lineとしてマッピングします。found_bugs.jsonl:1行につき1件の検出結果です。- その他の
*.jsonで、トップレベルがオブジェクトのリスト、またはfindings/results/issues/vulnerabilities配列を含むオブジェクトの場合:その配列を使用します。
- 単一の
.json/.jsonlファイル:上記と同じルールで認識します。 - Markdown/テキスト:レベル2/3の見出しまたは
---の区切りで分割します。各セクションから、パターン(File:、Line:、Severity:のラベルまたはpath:NNの箇所)に基づいてfile、line、category、severity、descriptionを抽出します。 ベストエフォートで処理し、source_format: "markdown_heuristic"としてマークします。
解析可能な情報が見つからない場合は、処理を停止して、確認できた内容を報告します。
1b. フィールドの正規化
各生レコードについて、検出結果の辞書を作成します。**存在する値を取得し、不足している値は決して推測しないでください。**フィールドのマッピング(入力キーのエイリアス → 正規名):
| 正規名 | 次のキーも受け付けます |
|---|---|
file | path、location.file、filename、ASAN のトップフレームのファイル |
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(0.0~1.0 に正規化) |
すべての検出結果に次の項目を付加します。
id:取り込み順にf001、f002、... とします。ほとんどの検出結果にscanner_confidenceがある場合は、信頼度の高い検出結果が先に検証され(部分出力にも先に表示される)よう、降順で取り込みます。それ以外の場合は、ソースの順序を維持します。これはスケジューリング上の事前情報にすぎず、判定には影響しません。source:元ファイルの相対パスとソース形式。missing_fields:欠落していた正規フィールドのリスト。fileがない場合、または--repo内で解決できない場合、その検出結果は場所を特定できません。重複排除と検証をスキップし、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"を付けて直接出力します。場所を特定できなかった検出結果に確信度の高い判定を出してはならず、重複排除によって他の検出結果を吸収したり、他の検出結果に吸収されたりしてはなりません。
1c. 対象コードベースの特定
--repo(デフォルトは cwd)を解決します。file を持つ最初の 5 件の検出結果について、そのパスがリポジトリ配下で解決されるかを確認します。次の順序で試します: (a) repo/file をそのまま; (b) file を絶対パスまたは cwd 相対パスとして; (c) file から一般的なプレフィックス(src/、app/、./、またはリポジトリ自身のベース名。例: --repo harness に対する harness/grade.py)を除去した repo/file。どの解決方法が機能したかを記録し、すべての検出結果に適用します。いずれも解決できない場合は、停止します: 検証にはソースへのアクセスが必要であり、引用されたファイルに到達できないことをユーザーに伝え、確認できる最長の共通サフィックスに基づいて --repo の値を提案します。
チェックポイント: Write ツール → ./.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>"}
次に Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 1 ingest --from ./.triage-state/_chunk.tmp
フェーズ 2: 重複排除(検証の前)
重複をまとめ、重複した検出結果がそれぞれ N 件の検証者を無駄に消費しないようにします。
2a. 決定論的パス(インライン、サブエージェントなし)
以下のすべてに該当する検出結果をクラスタリングします:
- 同じ
file(パス正規化後)、かつ - 同じ
category(大文字小文字を区別せず、句読点を除去)、かつ line番号が互いに 10 以内。両方欠落している場合は一致し、片方のみ欠落している場合は一致しません(行番号のないレコードが、位置が特定されたレコードを吸収してはなりません)。
各クラスタ内で、正規レコードは missing_fields が最も少ないレコードです。同数の場合は最も小さい id が優先されます。他のすべてのメンバーには verdict: duplicate、duplicate_of: <canonical id> が付与され、ワーキングセットから削除されます。重複した id は正規レコードに absorbed: [...] として記録します。
2b. セマンティックパス(1 つのサブエージェント、1 つを超えるクラスタが残存する場合のみ)
subagent_type: "general-purpose" で 1 つの Task を起動し、次のプロンプトを渡します:
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"}
GROUP: 行を解析します。それぞれについて、リストされた重複 id に verdict: duplicate、duplicate_of: <canonical> を付与し、正規レコードの absorbed に追加して、ワーキングセットから削除します。
candidates[] = 生き残った正規レコードを引き継ぎます。
チェックポイント: Write ツール → ./.triage-state/_chunk.tmp:
{"phase": 2, "context": {...}, "findings": [ {all findings; duplicates carry verdict/duplicate_of} ], "candidates": ["f001", "f003", "..."]}
次に Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 2 dedup --from ./.triage-state/_chunk.tmp
フェーズ 3: 検証
各候補について、N 件の独立した敵対的検証者がコードから主張を再導出し、投票します。各検証者の立場は「これが誤りである理由を見つける」ことです。各検証者はスキャナーの説明ではなく、引用された位置のコードから開始し、他の検証者の推論を一切見ません(共有コンテキストは盲点を伝播させます)。
3a. 検証者プロンプト(一度組み立て、すべての起動で再利用)
You are a skeptical security engineer adversarially verifying ONE finding
from an automated scanner. Your default assumption is that the scanner is
WRONG. Your job is to re-derive the claim from the source code yourself and
decide TRUE_POSITIVE or FALSE_POSITIVE.
You have read-only access to the target codebase at: {REPO_PATH}
You may use Read, Glob, and Grep, but ONLY on paths inside {REPO_PATH}.
Do NOT read, grep, or glob outside that root: anything outside it (the
triage pipeline itself, scanner outputs, fixtures, other repos on disk) is
out of scope and citing it contaminates your verdict. If a finding's
`file` resolves outside {REPO_PATH}, return CANNOT_VERIFY with
REFUTE_REASON: doesnt_exist. You may NOT build, run, or test the target,
install dependencies, or reach the network. Every conclusion must come
from reading source under {REPO_PATH}.
ENVIRONMENT (from the operator; this defines the trust boundary):
{context.environment or "Unknown. Treat any externally-reachable entry point as untrusted."}
────────────────────────────────────────────────────────────────────────
PROCEDURE: follow all four steps. Each exists because skipping it lets a
specific false-positive class through.
1. READ THE CODE AT THE CITED LOCATION YOURSELF.
Open {file} at line {line}. Understand what the code actually does. Do
NOT trust the scanner's description: scanners misread code surprisingly
often, and if you start from the summary you inherit the misreading.
2. TRACE REACHABILITY BACKWARDS FROM THE SINK.
Grep for callers of this function/method. Follow imports. Establish
whether attacker-controlled input (per the ENVIRONMENT above) can
actually reach this line. A plausible-sounding chain is NOT enough: for
at least the FIRST link in the chain, READ the actual call site and
QUOTE the file:line in your rationale. Unreachable code is the single
largest false-positive source.
3. HUNT FOR PROTECTIONS.
Actively look for reasons the finding is WRONG:
- Input validation / sanitization upstream of the sink
- Framework auto-escaping, parameterized queries, prepared statements
- Type constraints (the value is an int, an enum, a fixed-length token)
- Authentication / authorization gates before this path
- Configuration that limits exposure (feature flag off, debug-only)
- Dead code, test-only code, example/fixture code
4. STRESS-TEST EACH PROTECTION.
For each protection you found: is it applied on EVERY path to the sink,
or only the one the scanner happened to trace? Are there encodings,
edge cases, or alternate entry points that bypass it?
────────────────────────────────────────────────────────────────────────
EXCLUSION RULES: if the finding matches any of these, it is FALSE_POSITIVE
even if technically accurate. Cite the rule number in your verdict.
1. Volumetric DoS or missing rate-limiting (handled at infrastructure
layer). ReDoS, algorithmic complexity, and unbounded recursion ARE
still valid findings.
2. Test-only code, dead code, example/fixture code, or a crash with no
security impact.
3. Behavior that is the intended design (compression middleware, a
backward-compatible weak algorithm offered alongside a strong one).
4. Memory-safety concerns in memory-safe languages outside `unsafe` /
FFI blocks.
5. SSRF where the attacker controls only the path, not the host or
protocol.
6. User input flowing into an AI/LLM prompt (prompt injection is not a
code vulnerability in the target).
7. Path traversal in object storage (S3/GCS) where `../` does not escape
a trust boundary.
8. Trusted inputs used as the attack vector (env vars, CLI flags set by
the operator), UNLESS the ENVIRONMENT above marks them untrusted.
9. Client-side code flagged for server-side vulnerability classes.
10. Outdated dependency versions (managed by a separate process).
11. Weak random used for non-security purposes (jitter, shuffling,
dev-only fallbacks).
12. Low-impact nuisance issues (log spoofing, CSRF on logout, self-XSS,
tabnabbing, open redirect, regex injection).
13. Missing hardening or best-practice gap with no concrete exploit path
(missing security headers, no audit logging, permissive config that
isn't actually reached by untrusted input).
14. XSS in a framework with default auto-escaping (React, Angular, Vue,
Jinja2 autoescape=on) UNLESS the sink is a raw-HTML escape hatch
(dangerouslySetInnerHTML, bypassSecurityTrustHtml, v-html, |safe).
15. Identifiers that are unguessable by construction (UUIDv4, 128-bit+
random tokens) flagged as "predictable" or "needs validation".
16. Race conditions or TOCTOU that are theoretical only — no realistic
window, or no security-relevant state changes between check and use.
{if context.extra_fp_rules: append here verbatim under an
"ORG-SPECIFIC RULES:" heading}
────────────────────────────────────────────────────────────────────────
VERDICT: your response MUST end with EXACTLY this block:
VERDICT: TRUE_POSITIVE | FALSE_POSITIVE | CANNOT_VERIFY
CONFIDENCE: <0-10>
REFUTE_REASON: <one of: 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 of the first call site you read, or "none found">
RATIONALE: <2-5 sentences citing specific file:line evidence for
reachability, protections found/absent, and why each held or didn't>
TRUE_POSITIVE requires ALL of: path is reachable from untrusted input per
the ENVIRONMENT; protections are insufficient or bypassable; real-world
exploitation is feasible.
FALSE_POSITIVE requires ANY of: unreachable from untrusted input;
adequately protected on all paths; scanner misread the code; an exclusion
rule applies.
CANNOT_VERIFY: static reasoning genuinely hit its limit (e.g. behavior
depends on runtime configuration you cannot read, or the code path crosses
into a binary you cannot inspect). Use sparingly; it must not become the
default.
3b. 候補ごとに N 個の検証エージェントを、すべて 1 つのメッセージ内で起動する
candidates[] 内の各検出項目について、N 個(N = --votes、デフォルト 3)の Task 呼び出しを、subagent_type: "general-purpose" および description: "verify {id} vote {k}/{N}" を指定して構築します。
常に subagent_type を設定してください。決してフォークしないでください。 subagent_type を省略するとオーケストレーターがフォークされ、フォークは会話コンテキスト全体を継承します。つまり、他のすべての検出項目の説明、スキャナーの文章、および以前の検証エージェントの結果を継承します。これは検証エージェントの独立性を損ない、このフェーズが防ぐために存在する継承フレーミングの故障モードを再び引き起こします。各検証エージェントは、まっさらで空のコンテキストから開始し、3a のプロンプトとレビュー対象の単一の検出項目のみを受け取る必要があります。同じことは 4a のランキング用サブエージェントにも当てはまります。
各プロンプトは、3a の検証エージェント用プロンプトに次のブロックを追加したものです:
────────────────────────────────────────────────────────────────────────
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.
すべての検証エージェントの Task 呼び出しを 1 つのアシスタントメッセージにまとめてください。そうすることで並行して実行されます。run_in_background は設定しないでください。必要なのは最終的なテキストであり、非同期ハンドルではありません。len(candidates) * N が約 40 を超える場合は、約 40 ずつの逐次バッチに分割しますが、各バッチは 1 つのメッセージに保ってください。
大規模時のプロンプトサイズ。 3a のプロンプトは約 1200 ワードです。candidates * votes > ~50 の場合は、代わりにこのコンパクトな形式を使用してください(手順と出力契約は同じで、文章は削除されています):
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 concrete exploit; 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.
file はあるが line がない検出項目は、--votes に関係なく1 票の検証エージェント票を取得します(ファイルレベルのスイープはコストが高く、投票による利点がありません)。
いずれかの Task 呼び出しが検証エージェントのテキストではなく status: "async_launched" を返した場合、ランタイムがそれをバックグラウンド化しています(一部のランタイムは大規模な並列バッチに対してこれを自動的に行います)。1 つのリカバリ方法を選び、バッチ全体に対してそれを使用してください:
- 会話内に完了通知が届く場合: 各検証エージェントの VERDICT ブロックを、通知の
resultから到着し次第パースしてください。すべての票が処理されるまでターンを終了しないでください。 - 通知が届かない場合: トランスクリプトファイルをポーリングしないでください。不足している検証エージェントを新しい Task バッチで再起動し(より小さなシャードサイズ、例: 10)、同期結果を使用してください。 同じリカバリ方法は、2b の重複排除サブエージェントおよび 4a のランキング用サブエージェントにも当てはまります。
3c. 投票を集計する
各候補について、N 個の検証者それぞれの末尾にあるブロックを解析します
(コードフェンスや空白は許容します)。検証者でエラーが発生した場合、
タイムアウトした場合、または解析可能な VERDICT ブロックが出力されなかった
場合は、もう一度起動します。再試行も失敗した場合、その票は
cannot_verify として数え、confidence: 0 を設定し、
refute_reasons に "verifier_error" を記録します。残りの N-1 票で
判定します。
以下を作成します。
vote_breakdown:{"true_positive": x, "false_positive": y, "cannot_verify": z}confidence:多数派と一致する票の CONFIDENCE の平均を、小数第 1 位に 丸めた値。exclusion_rule:FALSE_POSITIVE 票のうち最頻の EXCLUSION_RULE。該当する票が なければnull。refute_reasons:FALSE_POSITIVE 票の REFUTE_REASON 値を重複なしで 並べ替えたもの。first_links:すべての票に含まれる FIRST_LINK 値を重複なしでまとめたもの (到達可能性の監査証跡)。rationale:勝者側で最も信頼度の高い票の RATIONALE をそのまま使用します。
verdict を決定します。
- TRUE_POSITIVE が多数派 →
verdict: true_positive。Phase 4 に進みます。 - FALSE_POSITIVE が多数派 →
verdict: false_positive。Phase 4 をスキップします。 - 多数派なし(同数、または CANNOT_VERIFY が多数派):
- ノイズ許容度が
precision→verdict: false_positive。rationale に"(split vote, dropped under precision policy)"を追加します。 - ノイズ許容度が
recall→verdict: true_positiveとし、verify_verdict: needs_manual_testを設定します。Phase 4 に進みます。 - ノイズ許容度が
ask→ Phase 3 の最後に、すべての票が分かれた指摘事項を 1 回の AskUserQuestion 呼び出しで提示します(ヘッダー:id + title、 選択肢:keep / drop)。その後、ユーザーの選択を適用します。
- ノイズ許容度が
verdict == true_positive の候補から confirmed[] を作成します。
チェックポイント: Write ツール → ./.triage-state/_chunk.tmp:
{"phase": 3, "context": {...}, "findings": [ {all findings with verdict/vote_breakdown/confidence/refute_reasons/first_links/rationale/exclusion_rule} ], "confirmed": ["f001", "..."]}
次に Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 3 verify --from ./.triage-state/_chunk.tmp
これは最もコストの高いチェックポイントです。len(candidates) * votes が
約 40 を超え、検証者の起動が逐次バッチに分割される場合は、票を集計する際に
候補ごとにもチェックポイントを作成します。
- Write ツール →
./.triage-state/_chunk.tmp= その指摘事項の集計後の dict。 - Bash:
python3 .claude/skills/_lib/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp
phase_done == 2 の状態から再開すると、Phase 3 のエントリポイントは
progress.json:shards_done を読み込みます(デフォルトは []。ディスク上の
シャードファイルを glob してはいけません。以前の実行で作成された古い
シャードが残っている可能性があります)。対応する shard_{id}.json ファイルを
読み込み、phase2.json の candidates[] にある id のうち shards_done に
含まれていないものだけについて検証者を起動します。すべての候補が
shards_done に含まれたら、上記のとおり、統合した phase3.json
チェックポイントを書き込みます。
Phase 4:悪用可能性でランク付けする(確認済みの指摘事項のみ)
カテゴリ名ではなく前提条件と到達可能性から重大度を再計算し、スキャナーが 主張する重大度は別途評価します。検証と重大度は独立した判断です。 「これは実在する」という判断を「これは重大な問題だ」に膨らませては いけません。
4a. ランク付けプロンプト
確認済みの指摘事項ごとに 1 つの Task を起動し
(subagent_type: "general-purpose"、すべてを 1 つのメッセージで)、
以下を渡します。
You are assigning severity to a CONFIRMED security finding. Verification
already happened; assume the finding is real. Your only job is to derive
how bad it is, independently of what the scanner claimed.
You may Read/Grep the codebase at {REPO_PATH} to check preconditions. Do
NOT execute code.
ENVIRONMENT: {context.environment}
THREAT MODEL (operator-stated, may be empty):
{context.threat_model as bullets, or "(none provided)"}
SCORING STANDARD: {context.scoring}
FINDING:
id: {id}
file: {file}:{line}
category: {category}
claimed severity: {severity}
reachability evidence: {first_links from Phase 3}
verifier rationale: {rationale from Phase 3}
────────────────────────────────────────────────────────────────────────
STEP 1: Enumerate EVERY precondition that must hold for exploitation.
Be concrete: required auth state, configuration, prior request, race
window, attacker position. Then state the minimum ACCESS LEVEL required
(unauthenticated remote / authenticated / local / physical).
STEP 2: Derive severity from the precondition count and access level:
| Preconditions | Access required | Severity |
|---------------|--------------------------|----------|
| 0 | Unauthenticated remote | HIGH |
| 1-2 | Authenticated | MEDIUM |
| 3+ | Local-only / no demo path| LOW |
Evaluate each column independently and take the LOWER result. Example:
0 preconditions but authenticated-only is MEDIUM, not HIGH; 1
precondition but local-only is LOW. Cross-check: if your preconditions
list has 3+ items, HIGH is almost certainly wrong.
STEP 3: Threat-model match. If the THREAT MODEL is non-empty and this
finding maps onto one of its entries, note which one. A match may raise
severity by ONE step (LOW to MEDIUM or MEDIUM to HIGH), never two. If the
threat model is empty, skip this step.
STEP 4: Judge the scanner's claimed severity. From the perspective of an
engineer who has reviewed two hundred scanner findings this week and is
allergic to inflation: would the CLAIMED severity contribute to alert
fatigue? Is it comparable to a real CVE at that level? Is the code in test
fixtures or dev-only config? Score in -5..+5:
+3..+5 claimed severity is justified or understated
0..+2 roughly right
-1..-3 inflated by one level
-4..-5 badly inflated (LOW dressed as HIGH)
STEP 5: verify_verdict. Exactly one of:
exploitable preconditions are realistically satisfiable
mitigated real, but a deployed control reduces it below the
derived severity (name the control)
needs_manual_test severity hinges on something only a runtime test can
settle; recommend a human build a PoC
STEP 6: If SCORING STANDARD is a CVSS or OWASP variant, emit a
`severity_label` in that format (vector string + base score for CVSS;
likelihood x impact for OWASP). Otherwise set it equal to the derived
HIGH/MEDIUM/LOW.
────────────────────────────────────────────────────────────────────────
Respond with ONLY this block:
PRECONDITIONS:
- <one per line>
ACCESS_LEVEL: <unauthenticated_remote|authenticated|local|physical>
SEVERITY: <HIGH|MEDIUM|LOW>
SEVERITY_LABEL: <per scoring standard>
THREAT_MATCH: <matched threat-model entry, or none>
SEVERITY_ALIGNMENT: <-5..+5>
VERIFY_VERDICT: <exploitable|mitigated|needs_manual_test>
RANK_RATIONALE: <2-4 sentences>
4b. 統合
確認済みの指摘事項ごとに、ブロックを解析して preconditions を付与します
(スキャナーが提示したリストがあれば置き換えます)。さらに、access_level、
severity(再計算した値)、severity_label、threat_match、
severity_alignment、verify_verdict を付与し、RANK_RATIONALE を
rationale に追加します(Phase 3 の rationale との間には空行を入れます)。
Phase 4 に進まなかった指摘事項(false_positive、duplicate、
位置を特定できないもの)については、severity: null、verify_verdict: null、
severity_alignment: null、preconditions: [] を設定します。
チェックポイント: Write ツール → ./.triage-state/_chunk.tmp:
{"phase": 4, "context": {...}, "findings": [ {all findings with severity/severity_label/preconditions/access_level/threat_match/severity_alignment/verify_verdict} ]}
次に Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 4 rank --from ./.triage-state/_chunk.tmp
フェーズ 5:振り分け
真陽性と確認された各項目に、推測可能な範囲で最も具体的なコンポーネントまたは担当者を割り当てます。confirmed[] 内の各指摘について、最初に該当した項目で処理を止めます。
- CODEOWNERS / OWNERS。
--repo内をCODEOWNERS、OWNERS、.github/CODEOWNERS、docs/CODEOWNERSで検索します。見つかった場合は、指摘のfileをパターンに照合します(最後に一致したものを適用)。ヒント:"CODEOWNERS: <pattern> -> <owner(s)>"。 - git log。
--repoが git チェックアウトの場合、次を実行します。git -C {REPO} log --format='%an' -n 50 -- "{file}" | sort | uniq -c | sort -rn | head -3。 ヒント:"top committer: <name> (<n>/<total> recent commits); no CODEOWNERS entry"。 - モジュールへのフォールバック。 ヒント:
"component: <top-level dir of file>/; no CODEOWNERS or git history"。
owner_hint として添付します。確信度が明確になるよう、情報源を記載します。ユーザー名だけよりも、"component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)" のほうが有用です。真陽性ではない指摘には、owner_hint: null を設定します。
チェックポイント: Write ツール → ./.triage-state/_chunk.tmp:
{"phase": 5, "context": {...}, "findings": [ {all findings with owner_hint} ]}
次に Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmp
フェーズ 6:出力
6a. 並べ替え
すべての指摘を次の順に並べます。
verdict:true_positive、次にduplicate、次にfalse_positive。- 真陽性内では、
severityの HIGH > MEDIUM > LOW の順、次にconfidenceの降順、次にseverity_alignmentの降順。 - その他では、元の
id順。
6b. ./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|null",
"confidence": 0.0,
"severity": "HIGH|MEDIUM|LOW|null",
"severity_label": "...",
"severity_alignment": 0,
"preconditions": ["..."],
"access_level": "...",
"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": ["..."]
}
]
}
入力された各指摘を必ず 1 回ずつ含めます(重複は duplicate_of で正規の指摘を参照します)。何も通知せずに項目を削除しないでください。この JSON をターミナルに出力せず、ファイルにのみ書き込みます。
6c. ./TRIAGE.md を書き込む
レビュー担当者向けのレポートです。段階的に作成します。ファイル全体を 1 回の Write で出力してはいけません。指摘ごとに 1 つのチャンクを作成します。チャンクが停滞しても、失われるのはそのセクションだけで、ファイル全体ではありません。
ステップ 1 — ヘッダー。 Write ツール → ./TRIAGE.md(既存のファイルがあれば上書き)に、タイトルブロック、概要、## 対応が必要な項目 の見出しのみを書き込みます。
# 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
ステップ 2 — 指摘ごと。 severity 順に、各 true_positive について:
- Write ツール →
./.triage-state/_chunk.tmpに、1 件の指摘のセクションのみを書き込みます。
### [{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}
**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.
- Bash:
python3 .claude/skills/_lib/checkpoint.py append ./TRIAGE.md --from ./.triage-state/_chunk.tmp
各 true_positive について繰り返します。
ステップ 3 — フッター。 Write ツール → ./.triage-state/_chunk.tmp に
Dropped テーブルを書き込み、同じ方法で checkpoint.py append を実行します。
## Dropped
| id | title | file:line | why dropped |
{false_positives: refute_reasons + exclusion_rule}
{duplicates: "duplicate of {duplicate_of}"}
{unlocatable: "no source location in input"}
チェックポイント(最終): Bash:
python3 .claude/skills/_lib/checkpoint.py done ./.triage-state 6
次回の呼び出しでは、再開チェックで status == "complete" が確認され、新規に開始されます。
6d. ターミナルの概要
約 12 行以内:
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
このスキルのテスト
スモークテスト(5 件のフィクスチャ:実際の指摘 2 件、重複 1 件、誤検知 2 件):
/triage .claude/skills/triage/fixtures/canary-findings.json --auto --repo targets/canary
期待結果:f001 と f003 は確認済み、f002 は f001 の重複、f004 は除外(misread_code:読み取りバッファであり、乱数のソースではありません)、f005 は除外(already_handled:68 行目に null チェックがあります)。
または、パイプラインの出力を使います。
vuln-pipeline run drlibs --runs 3 --parallel --stream
/triage results/drlibs/<ts>/ --repo targets/drlibs
TRUE_POSITIVE/HIGH の結果をいくつか手作業で確認します(first_links が実際の呼び出し箇所を指していること)。また、FALSE_POSITIVE として却下された結果もいくつか確認します(exclusion_rule または refute_reasons に妥当な根拠があること)。
設計上の注意事項
- チェックポイントは会話状態ではなく、フェーズごとの JSON です。 パイプラインの
--resume <session_id>(docs/pipeline.md)ではトランスクリプトの履歴を復元できますが、 オーケストレーター自体のコンテキストウィンドウがいっぱいになった場合には役立ちません。 ファイルベースのチェックポイントを使えば、新しいセッションで最後に完了したフェーズから 再開できます。./.triage-state/は一時領域です。.gitignoreに追加してください。 - 重複排除は検証より先に実行し、安価なサブエージェントを 1 つ使う代わりに、 検証コストを重複率分(複数スキャナーからの入力では通常 2~4 倍)削減します。
- セマンティックな重複排除は 1 つのエージェントで行います。 渡す情報は id/file/line/category/title のみです。 これでクラスタリングには十分ですが、あるスキャナーの推論が別の検出結果の検証に 漏れ込むことはありません。
- Bash の使用は
git log(所有者のヒント取得)、jq/find(取り込み)、python3 .claude/skills/_lib/checkpoint.py(状態の入出力)に限定して許可します。 実際に維持される安全性の要件は「対象コードを実行しないこと」です。 CANNOT_VERIFYは、検証担当者に誤った二者択一を強いないための値です。 再現率重視のポリシーではneeds_manual_testに、適合率重視のポリシーでは除外に対応します。- 脅威モデルによる引き上げは 1 段階までに制限されています。これにより、明示された脅威が LOW を再び HIGH に引き上げ、前提条件のルールを無効にすることを防ぎます。
severity_labelはseverityとは別です。 並べ替えには常に前提条件から導出された HIGH/MEDIUM/LOW を使います。ラベルは、レビュアーのツールが想定する標準に合わせるための 表示レイヤーです。- パイプラインの
report.jsonの取り込みはベストエフォートです。 これらのレポートには、 静的検証ツールが想定する file/line/category 形式ではなく、ASAN クラッシュについての 悪用可能性分析が文章で記載されています。この入力では、静的スキャナーの JSON よりもneeds_manual_test判定が多くなることが予想されます。 - 並列タスク約 40 件でのシャーディングは、一般的なエージェントの起動上限を考慮した保守的な上限です。 実行環境が許容する場合は、上限を引き上げてください。
- 意図的にネットワークを使用しません。 CVE データベースによる情報の補強や上流修正の確認は ランキングに役立ちますが、エアギャップ環境でレビューできる特性が損なわれます。