triage
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
Chinesetriage
漏洞分类处理
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 ; positional / expansion is
not stable across runtimes):
$ARGUMENTS$1$2- findings path (first positional, required): a JSON file, a directory of
JSON files, a , a pipeline
VULN-FINDINGS.jsondirectory, or a markdown report.results/<target>/<ts>/ - : skip the interview and use defaults. Default mode is interactive.
--auto - : verifier votes per finding (default 3; use 1 for a quick pass, 5 for high-stakes batches).
--votes N - : 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.
--repo PATH - : 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.
--fp-rules FILE - : ignore any existing checkpoint in
--freshand start from Phase 0. Without this flag the skill resumes from the last completed phase if a checkpoint is present../.triage-state/
Tools: Read, Glob, Grep, Write, Task, AskUserQuestion. Bash is
permitted only for , , , , , and
(checkpoint I/O).
gitfindwclsjqpython3 .claude/skills/_lib/checkpoint.pyDo 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.
对原始安全扫描器输出进行对抗性分类处理,完成四项工作:验证每个检测结果是否真实存在,去重跨扫描运行和不同扫描器的重复结果,排序剩余结果(基于推导的可利用性而非扫描器声称的严重程度),以及分配每个结果到对应组件责任人。输出是一个简短、已排序且标记责任人的列表,而非原始扫描输出。
调用方式:。
/triage <检测结果路径> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE]参数(从解析;位置参数/的扩展在不同运行时不稳定):
$ARGUMENTS$1$2- 检测结果路径(第一个位置参数,必填):JSON文件、JSON文件目录、、流水线
VULN-FINDINGS.json目录,或markdown报告。results/<target>/<ts>/ - :跳过交互问答,使用默认设置。默认模式为交互模式。
--auto - :每个检测结果的验证者投票数(默认3;快速检查用1,高风险批次用5)。
--votes N - :目标代码库的路径,只读(默认当前工作目录)。验证需要访问源代码;如果引用的文件无法访问,该工具会报错停止。
--repo PATH - :将FILE的内容追加到验证者的排除规则列表(Phase 3a)。用于组织特定先例:“我们全程使用Prisma ORM — 仅原生查询存在SQL注入风险”“k8s资源限制覆盖拒绝服务风险”等。纯文本格式,每条规则占一行或一段。
--fp-rules FILE - :忽略
--fresh中任何现有检查点,从Phase 0开始。如果没有此标志,若存在检查点,工具会从最后完成的阶段恢复。./.triage-state/
工具:Read、Glob、Grep、Write、Task、AskUserQuestion。仅允许Bash执行、、、、和(检查点I/O)。
gitfindwclsjqpython3 .claude/skills/_lib/checkpoint.py禁止执行目标代码。禁止编译、运行、安装依赖或发送请求。意外对真实系统生效的概念验证是不可接受的,“无法编写可行的PoC”不能作为非可利用性的有力证据。所有结论必须来自对源代码的阅读。此约束适用于编排器和所有子代理;需在每个Task提示中包含该约束。对于高置信度的HIGH级结果,建议后续由人工构建PoC。
禁止访问网络。禁止查询包注册表、CVE数据库或获取上游提交记录。
Checkpointing (runs before Phase 0 and after every phase)
检查点机制(在Phase 0之前和每个阶段之后运行)
On large finding batches a full run can exhaust context or hit rate limits
mid-way — particularly Phase 3, which spawns verifiers.
Phase state persists to so a fresh session can
resume without re-asking the interview or re-spawning verifiers.
candidates × votes./.triage-state//triageAll checkpoint I/O goes through
(atomic writes, JSON-validated). Never use the Write tool for
directly. Never pass payload via heredoc or stdin; target-derived strings
could collide with the heredoc delimiter and break out to shell. The
Write→ pattern keeps repo-derived bytes out of Bash argv.
python3 .claude/skills/_lib/checkpoint.pyprogress.json--fromState files in :
./.triage-state/- — single source of truth for resume position:
progress.json. Resume decisions read ONLY this file, never a glob of{"status": "running"|"complete", "phase_done": N, "shards_done": [...]}or shard files (stale files from a prior run must not be trusted).phase*.json - — data payload for phase N (schemas at the tail of each phase section below).
phaseN.json - — transient payload buffer; overwritten before every
_chunk.tmp/save/shardcall.append
Start of run — resume check. Bash:
python3 .claude/skills/_lib/checkpoint.py load ./.triage-state- OR
status == "absent", OR"complete"in--fresh→ fresh start. Bash:$ARGUMENTS, then proceed to Phase 0.python3 .claude/skills/_lib/checkpoint.py reset ./.triage-state - with
status == "running"→ resume. Readphase_done == Nthrough./.triage-state/phase0.jsonin order (and anyphaseN.jsonfiles listed inshard_*.json), merging keys into working state (later files override earlier — checkpoints may be deltas). Printshards_done, and skip directly to Phase N+1.Resuming from checkpoint: Phase N complete (./.triage-state/phaseN.json)
End of every phase N. Two tool calls:
- Write tool → containing the phase's output JSON (schema at the tail of each phase section).
./.triage-state/_chunk.tmp - Bash →
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state <N> <name> --from ./.triage-state/_chunk.tmp
End of run. After writing and , Bash:
TRIAGE.jsonTRIAGE.mdpython3 .claude/skills/_lib/checkpoint.py done ./.triage-state 6在处理大量检测结果时,完整运行可能会耗尽上下文或中途触发速率限制 — 尤其是Phase 3,会生成个验证者。阶段状态会持久化到,因此新的会话可以从最后完成的阶段恢复,无需重新进行交互问答或重新生成验证者。
候选数 × 投票数./.triage-state//triage所有检查点I/O都通过完成(原子写入,JSON验证)。禁止直接使用Write工具操作。禁止通过 heredoc 或标准输入传递负载;目标派生的字符串可能与heredoc分隔符冲突,导致shell逃逸。Write→模式可避免将代码库派生的字节传入Bash参数。
python3 .claude/skills/_lib/checkpoint.pyprogress.json--from./.triage-state/- — 恢复位置的唯一可信来源:
progress.json。恢复决策仅读取此文件,绝不读取{"status": "running"|"complete", "phase_done": N, "shards_done": [...]}的全局匹配或分片文件(之前运行的过期文件不可信)。phase*.json - — Phase N的数据负载(每个阶段部分末尾有对应的 schema)。
phaseN.json - — 临时负载缓冲区;每次
_chunk.tmp/save/shard调用前会被覆盖。append
运行开始 — 恢复检查。执行Bash命令:
python3 .claude/skills/_lib/checkpoint.py load ./.triage-state- 或
status == "absent",或"complete"中包含$ARGUMENTS→ 全新启动。执行Bash命令:--fresh,然后进入Phase 0。python3 .claude/skills/_lib/checkpoint.py reset ./.triage-state - 且
status == "running"→ 恢复运行。按顺序读取phase_done == N到./.triage-state/phase0.json(以及phaseN.json中列出的任何shards_done文件),将密钥合并到工作状态(后续文件覆盖先前文件 — 检查点可能是增量)。打印shard_*.json,并直接跳转到Phase N+1。Resuming from checkpoint: Phase N complete (./.triage-state/phaseN.json)
每个阶段N结束时。执行两个工具调用:
- Write工具 → 将阶段输出JSON写入(每个阶段部分末尾有对应的schema)。
./.triage-state/_chunk.tmp - Bash →
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state <N> <name> --from ./.triage-state/_chunk.tmp
运行结束。写入和后,执行Bash命令:
TRIAGE.jsonTRIAGE.mdpython3 .claude/skills/_lib/checkpoint.py done ./.triage-state 6Phase 0: Mode select and interview
Phase 0:模式选择与交互问答
0a. Parse arguments
0a. 解析参数
From : extract the findings path (first positional),
flag, (default 3), (default ), (default none). If no findings path was given, ask for one and stop.
If was given, Read the file now and carry its contents as
for injection into the Phase 3a verifier prompt.
$ARGUMENTS--auto--votes N--repo PATH.--fp-rules FILE--fp-rulescontext.extra_fp_rules从中提取:检测结果路径(第一个位置参数)、标志、(默认3)、(默认)、(默认无)。如果未提供检测结果路径,询问用户并停止。如果提供了,立即读取该文件,并将内容作为带入Phase 3a的验证者提示中。
$ARGUMENTS--auto--votes N--repo PATH.--fp-rules FILE--fp-rulescontext.extra_fp_rules0b. Interactive mode (default): interview the user
0b. 交互模式(默认):与用户交互
Unless 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.
--autoRound 1 (single AskUserQuestion call):
-
Environment & trust boundary (header, single-select)
EnvironmentOptions:What kind of system are these findings from, and where does untrusted input enter it?,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). 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.Embedded / firmware (physical access in scope) -
Threat model (header, multi-select)
Threat modelOptions:What does a worst-case attacker look like for this system, and what must never happen? Free text is best.,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. Phase 4 boosts findings that map onto a stated threat.Compliance-scoped data exposure (PII / PCI / PHI) -
Scoring standard (header, single-select)
ScoringOptions:How should severity be expressed in the output?,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). The precondition rule is always computed; this controls whatOrganization bug-bar (describe in Other)additionally shows.severity_label -
Noise tolerance (header, single-select)
Noise toleranceOptions:When verifiers disagree, which way should ties break?,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 , ask one targeted follow-up.
Organization bug-barRecord the answers as a dict carried through every phase and
echoed in the output under .
contexttriage_context除非传入,否则使用AskUserQuestion收集影响验证和排序的上下文信息。将问题分批为1-2次调用,每次最多4个问题。接受“其他”选项下的自由文本回答;多选选项仅作为提示,而非约束。
--auto第一轮(单次AskUserQuestion调用):
-
环境与信任边界(标题,单选)
Environment选项:这些检测结果来自哪种系统,不受信任的输入从何处进入系统?、面向互联网的Web服务(HTTP为不受信任来源)、内部服务(调用者为已认证的对等方)、库/SDK(调用者为信任边界)、CLI/批处理工具(操作员输入可信,文件输入不可信)。 可达性基于此边界判断;“环境变量导致命令注入”在多租户Web服务中是真阳性,而在操作员CLI中是规则8下的假阳性。嵌入式/固件(物理访问在范围内) -
威胁模型(标题,多选)
Threat model选项:该系统的最坏情况攻击者是什么样的,绝对不能发生的情况是什么?最好使用自由文本回答。、未认证远程代码执行、租户间数据泄露、权限提升至管理员、下游用户供应链受损、违反付费SLA的拒绝服务。 Phase 4会提升与声明威胁匹配的结果优先级。合规范围内的数据泄露(PII/PCI/PHI) -
评分标准(标题,单选)
Scoring选项:输出中应如何表示严重程度?、从前提条件推导HIGH/MEDIUM/LOW(默认)、CVSS v3.1向量 + 基础分数、CVSS v4.0向量 + 基础分数、OWASP风险评级(可能性×影响)。 前提条件规则始终会计算;此选项控制组织漏洞阈值(在“其他”中描述)额外显示的内容。severity_label -
噪声容忍度(标题,单选)
Noise tolerance选项:当验证者意见不一致时,平局应如何处理?、精确性:丢弃所有未获得多数确认的结果(假阳性更少,可能遗漏真实漏洞)、召回率:将分歧投票标记为needs_manual_test(需审查的内容更多,遗漏更少)。每个结果出现分歧时询问我
第二轮(条件触发):如果威胁模型回答为空或通用,或评分回答为,则询问一个针对性的后续问题。
组织漏洞阈值将答案记录为字典,带入每个阶段,并在输出的中回显。
contexttriage_context0c. Auto mode defaults
0c. 自动模式默认值
When is set, do not call AskUserQuestion. Use:
--auto- 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.tmpjson
{"phase": 0, "context": {mode, environment, threat_model, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}Then Bash:
On resume past Phase 0, the interview is not re-asked; is
restored from this file.
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 0 interview --from ./.triage-state/_chunk.tmpcontext当设置时,不调用AskUserQuestion。使用以下默认值:
--auto- 环境:
未知。将任何外部可达的入口点视为不受信任;在理由中明确标记信任边界假设。 - 威胁模型:空(无优先级提升)。
- 评分:推导HIGH/MEDIUM/LOW。
- 噪声容忍度:精确性。
检查点:Write工具 → 写入:
./.triage-state/_chunk.tmpjson
{"phase": 0, "context": {mode, environment, threat_model, scoring, noise_tolerance, votes_per_finding, repo, findings_path}}然后执行Bash命令:
恢复到Phase 0之后的运行时,不会重新进行交互问答;从此文件恢复。
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 0 interview --from ./.triage-state/_chunk.tmpcontextPhase 1: Ingest and normalize
Phase 1:导入与标准化
Turn the input into a flat list with stable ids, regardless of
source format.
findings[]将输入转换为具有稳定ID的扁平列表,无论源格式如何。
findings[]1a. Detect input shape
1a. 检测输入格式
Inspect the findings path:
- Directory: Glob for and
**/*.json. Recognized containers, in priority order:**/*.jsonl- (a
VULN-FINDINGS.jsoncontainer): read{findings: [...]}..findings[] - or
reports/bug_*/report.json(this repo's pipeline output): one finding perreports/manifest.jsonl. Mapbug_NN→crash.crash_type,category→verdict.severity_rating, the proseseverity→report, crash file from the ASAN top frame →description/file.line - : one finding per line.
found_bugs.jsonl - Any other whose top level is a list of objects, or an object with a
*.json/findings/results/issuesarray: that array.vulnerabilities
- Single /
.jsonfile: same recognition as above..jsonl - Markdown / text: split on level-2/3 headings or rules; for each section, extract
---,file,line,category,severityby pattern (description,File:,Line:labels orSeverity:spans). Best-effort; markpath:NN.source_format: "markdown_heuristic"
If nothing parseable is found, stop and report what was seen.
检查检测结果路径:
- 目录:全局匹配和
**/*.json。按优先级识别以下容器:**/*.jsonl- (
VULN-FINDINGS.json容器):读取{findings: [...]}。.findings[] - 或
reports/bug_*/report.json(此仓库的流水线输出):每个reports/manifest.jsonl对应一个检测结果。将bug_NN映射为crash.crash_type,category映射为verdict.severity_rating, prose形式的severity映射为report,ASAN栈顶帧的崩溃文件映射为description/file。line - :每行对应一个检测结果。
found_bugs.jsonl - 任何其他顶级为对象列表,或包含/
findings/results/issues数组的vulnerabilities:使用该数组。*.json
- 单个/
.json文件:与上述识别逻辑相同。.jsonl - Markdown/文本:按二级/三级标题或规则拆分;对于每个部分,通过模式(
---、File:、Line:标签或Severity:片段)提取path:NN、file、line、category、severity。 尽最大努力提取;标记description。source_format: "markdown_heuristic"
如果未找到可解析的内容,停止并报告所见情况。
1b. Normalize fields
1b. 标准化字段
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 |
|---|---|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
Attach to every finding:
- :
id,f001, ... in ingest order. Iff002is 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.scanner_confidence - : relative path of the file it came from, plus source format.
source - : list of canonical fields that were absent. If
missing_fieldsis missing or does not resolve underfile, the finding is unlocatable: it skips dedup and verification and is emitted directly with--repo,verdict: false_positive,verify_verdict: needs_manual_test,confidence: 0,refute_reasons: ["doesnt_exist"]. Never emit a confident verdict on a finding you could not locate, and never let it absorb or be absorbed by dedup.rationale: "no source location in input; cannot verify statically; human review required"
对于每个原始记录,构建检测结果字典。提取存在的字段;绝不猜测缺失的字段。字段映射(源键别名 → 规范键):
| 规范键 | 可接受的别名 |
|---|---|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
为每个检测结果附加:
- :按导入顺序为
id、f001...。如果大多数检测结果包含f002,则按该字段降序导入,以便高信号结果优先验证(并在部分输出中显示);否则保持源顺序。这仅为调度优先级 — 不影响 verdict。scanner_confidence - :文件的相对路径,加上源格式。
source - :缺失的规范字段列表。如果
missing_fields缺失或在file下无法解析,该检测结果为无法定位:跳过去重和验证,直接输出--repo、verdict: false_positive、verify_verdict: needs_manual_test、confidence: 0、refute_reasons: ["doesnt_exist"]。绝不对无法定位的检测结果给出可信 verdict,也绝不允许其参与去重合并或被合并。rationale: "输入中无源代码位置;无法静态验证;需人工审查"
1c. Locate the target codebase
1c. 定位目标代码库
Resolve (default cwd). For the first 5 findings with a ,
check the path resolves under the repo. Try, in order: (a)
as-given; (b) as an absolute or cwd-relative path; (c)
with common prefixes stripped from (, , , or the
repo's own basename, e.g. with ).
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 value based on the
longest common suffix you can see.
--repofilerepo/filefilerepo/filefilesrc/app/./harness/grade.py--repo harness--repoCheckpoint: Write tool → :
./.triage-state/_chunk.tmpjson
{"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/_lib/checkpoint.py save ./.triage-state 1 ingest --from ./.triage-state/_chunk.tmp解析(默认当前工作目录)。对于前5个包含的检测结果,检查路径是否在代码库下可解析。按以下顺序尝试:(a) 按给定路径;(b) 作为绝对路径或当前工作目录相对路径;(c) 从中去除常见前缀(、、或代码库自身的basename,例如时的)后的。记录有效的解析方式,并应用于所有检测结果。如果均无法解析,停止:告知用户验证需要源代码访问,且引用的文件无法访问,并根据所见的最长公共后缀建议值。
--repofilerepo/filefilefilesrc/app/./--repo harnessharness/grade.pyrepo/file--repo检查点:Write工具 → 写入:
./.triage-state/_chunk.tmpjson
{"phase": 1, "context": {...}, "findings": [ {带id/source/file/line/category/...的标准化检测结果字典} ], "path_resolution": "<a/b/c中有效的方式>"} 然后执行Bash命令:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 1 ingest --from ./.triage-state/_chunk.tmpPhase 2: Deduplicate (before verification)
Phase 2:去重(验证前)
Collapse repeats so duplicate findings don't each burn N verifiers.
合并重复结果,避免重复的检测结果消耗N个验证者资源。
2a. Deterministic pass (inline, no subagent)
2a. 确定性去重(内联,无子代理)
Cluster findings where all of:
- same (after path normalization), AND
file - same (case-insensitive, punctuation stripped), AND
category - numbers within 10 of each other. Both-missing matches; one-side- missing does NOT (a line-less record must not absorb a located one).
line
Within each cluster, the canonical is the record with the fewest
; ties break to lowest . Every other member gets
, , and is removed from
the working set. Record duplicate ids on the canonical as .
missing_fieldsidverdict: duplicateduplicate_of: <canonical id>absorbed: [...]将满足以下所有条件的检测结果聚类:
- 相同(路径标准化后),且
file - 相同(不区分大小写,去除标点),且
category - 编号相差在10以内。双方都缺失line时匹配;仅一方缺失时不匹配(无line的记录不能合并有line的记录)。
line
在每个聚类中,规范记录为最少的记录;平局时选择ID最小的记录。其他成员标记为、,并从工作集中移除。在规范记录的中记录重复ID。
missing_fieldsverdict: duplicateduplicate_of: <规范ID>absorbed: [...]2b. Semantic pass (one subagent, only if >1 cluster survives)
2b. 语义去重(一个子代理,仅当>1个聚类存活时)
Spawn ONE Task with and this prompt:
subagent_type: "general-purpose"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 lines. For each, mark the listed dup ids with
, , append them to the
canonical's , and drop them from the working set.
GROUP:verdict: duplicateduplicate_of: <canonical>absorbedCarry forward = the surviving canonicals.
candidates[]Checkpoint: Write tool → :
./.triage-state/_chunk.tmpjson
{"phase": 2, "context": {...}, "findings": [ {all findings; duplicates carry verdict/duplicate_of} ], "candidates": ["f001", "f003", "..."]}Then Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 2 dedup --from ./.triage-state/_chunk.tmp生成一个Task,,提示如下:
subagent_type: "general-purpose"你正在对安全检测结果进行去重,以避免昂贵的验证工作。如果修复一个结果也能修复另一个,则这两个结果是重复的。如果两个结果有真正独立的根本原因,即使它们共享类别或文件,也是不同的。
视为重复的情况:
- 同一根本原因用不同措辞描述或由不同扫描器报告
- 共享的易受攻击辅助函数在每个调用站点被报告一次
- 缺失全局保护(认证检查、输出编码)在每个缺失该保护的端点被报告一次
- 同一代码路径中的原因(“`name`缺失输入验证”)及其后果(“通过`name`进行SQL注入”)
视为不同的情况:
- 同一文件区域中的不同类别(“ssrf”附近的“buffer_overflow”不会因为行号接近而被视为重复)
- 同一文件、同一类别,但不同污染变量到达不同的 sink
- 同一辅助函数,但内部有两个独立漏洞
- 两个端点缺失相同检查,但修复需针对每个端点而非共享网关
以下是候选检测结果(每行一个:id | file:line | category | title)。对它们进行分组。仅返回以下格式的行:
GROUP: <规范ID> <- <重复ID>, <重复ID>, ...
每个有重复的组占一行。省略无重复的记录。选择最具体/描述最清晰的结果作为规范记录。不要添加 prose。
候选结果:
{每个存活结果一行:"f003 | src/auth.py:112 | sql_injection | 用户查询将name拼接进SQL语句"} 解析行。对于每行,将列出的重复ID标记为、,将它们追加到规范记录的中,并从工作集中移除。
GROUP:verdict: duplicateduplicate_of: <规范ID>absorbed将设为存活的规范记录。
candidates[]检查点:Write工具 → 写入:
./.triage-state/_chunk.tmpjson
{"phase": 2, "context": {...}, "findings": [ {所有检测结果;重复结果带有verdict/duplicate_of} ], "candidates": ["f001", "f003", "..."]} 然后执行Bash命令:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 2 dedup --from ./.triage-state/_chunk.tmpPhase 3: Verify
Phase 3:验证
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).
对于每个候选结果,N个独立的对抗性验证者从代码中重新推导声明并投票。每个验证者的立场是“找出任何证明该结果错误的理由”。每个验证者从引用位置的代码开始,而非扫描器的描述,且永远看不到其他验证者的推理(共享上下文会传播盲点)。
3a. Verifier prompt (assemble once, reuse for every spawn)
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.你是一名持怀疑态度的安全工程师,正在对抗性地验证自动化扫描器的一个检测结果。你的默认假设是扫描器是错误的。你的工作是从源代码中重新推导声明,并决定是TRUE_POSITIVE还是FALSE_POSITIVE。
你对{REPO_PATH}处的目标代码库有只读访问权限。你可以使用Read、Glob和Grep,但仅能用于{REPO_PATH}内的路径。禁止读取、grep或全局匹配该根目录外的内容:任何外部内容(分类处理流水线本身、扫描器输出、测试用例、磁盘上的其他仓库)均超出范围,引用它们会污染你的 verdict。如果检测结果的`file`在{REPO_PATH}外可解析,返回CANNOT_VERIFY并标记REFUTE_REASON: doesnt_exist。禁止编译、运行或测试目标代码,禁止安装依赖或访问网络。所有结论必须来自对{REPO_PATH}下源代码的阅读。
环境(来自操作员;定义信任边界):
{context.environment或"未知。将任何外部可达的入口点视为不受信任。"}
────────────────────────────────────────────────────────────────────────
流程:遵循以下四个步骤。每个步骤的存在都是因为跳过它会让特定类别的假阳性通过。
1. 自行阅读引用位置的代码。
打开{file}的第{line}行。理解代码实际执行的逻辑。不要信任扫描器的描述:扫描器经常误读代码,如果你从摘要开始,会继承这种误读。
2. 从Sink反向追踪可达性。
Grep该函数/方法的调用者。跟踪导入。确定攻击者可控输入(根据上述环境)是否真的能到达该行。看似合理的链是不够的:对于链中的第一个环节,必须阅读实际的调用站点并在理由中引用file:line。不可达代码是最大的假阳性来源。
3. 寻找防护措施。
主动寻找证明该结果错误的理由:
- Sink上游的输入验证/清理
- 框架自动转义、参数化查询、预编译语句
- 类型约束(值为整数、枚举、固定长度令牌)
- 该路径前的认证/授权网关
- 限制暴露的配置(功能标志关闭、仅调试可用)
- 死代码、仅测试代码、示例/测试用例代码
4. 压力测试每个防护措施。
对于每个找到的防护措施:它是否应用于所有到达Sink的路径,还是仅扫描器追踪的那条路径?是否存在编码、边缘情况或替代入口点可以绕过它?
────────────────────────────────────────────────────────────────────────
排除规则:如果检测结果匹配以下任何规则,即使技术上准确,也视为FALSE_POSITIVE。在verdict中引用规则编号。
1. 容量型拒绝服务或缺失速率限制(由基础设施层处理)。ReDoS、算法复杂度和无界递归仍然是有效的检测结果。
2. 仅测试代码、死代码、示例/测试用例代码,或无安全影响的崩溃。
3. 预期设计的行为(压缩中间件、与强算法一起提供的向后兼容弱算法)。
4. 内存安全语言中`unsafe`/FFI块外的内存安全问题。
5. 攻击者仅能控制路径而非主机或协议的SSRF。
6. 用户输入流入AI/LLM提示(提示注入不是目标代码的漏洞)。
7. 对象存储(S3/GCS)中的路径遍历,其中`../`不会逃逸信任边界。
8. 受信任输入作为攻击向量(环境变量、操作员设置的CLI标志),除非上述环境将其标记为不受信任。
9. 客户端代码被标记为服务器端漏洞类别。
10. 过时的依赖版本(由单独流程管理)。
11. 用于非安全目的的弱随机数(抖动、洗牌、仅开发环境回退)。
12. 低影响的 nuisance 问题(日志伪造、注销时的CSRF、自XSS、标签劫持、开放重定向、正则注入)。
13. 缺失加固或最佳实践差距,但无具体利用路径(缺失安全头、无审计日志、未被不受信任输入访问的宽松配置)。
14. 默认自动转义的框架(React、Angular、Vue、Jinja2 autoescape=on)中的XSS,除非Sink是原始HTML逃逸出口(dangerouslySetInnerHTML、bypassSecurityTrustHtml、v-html、|safe)。
15. 构造上不可猜测的标识符(UUIDv4、128位+随机令牌)被标记为“可预测”或“需要验证”。
16. 仅理论上的竞争条件或TOCTOU — 无现实窗口,或检查与使用之间无安全相关状态变化。
{如果context.extra_fp_rules存在:在此处逐字追加,标题为"ORG-SPECIFIC RULES:"}
────────────────────────────────────────────────────────────────────────
VERDICT:你的回复必须以以下块结尾:
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, 组织规则,或none>
FIRST_LINK: <你阅读的第一个调用站点的file:line,或"none found">
RATIONALE: <2-5句话,引用具体file:line证据说明可达性、找到/缺失的防护措施,以及每个防护措施有效或无效的原因>
TRUE_POSITIVE需要满足所有条件:路径可从环境定义的不受信任输入到达;防护措施不足或可绕过;现实世界中可被利用。
FALSE_POSITIVE满足任何条件:无法从不受信任输入到达;所有路径均有足够防护;扫描器误读代码;符合排除规则。
CANNOT_VERIFY:静态推理确实达到了极限(例如行为依赖于无法读取的运行时配置,或代码路径进入无法检查的二进制文件)。谨慎使用;不能将其作为默认选项。3b. Spawn N verifiers per candidate, all in one message
3b. 为每个候选结果生成N个验证者,全部在一条消息中
For each finding in , build N Task calls (N = ,
default 3) with and .
candidates[]--votessubagent_type: "general-purpose"description: "verify {id} vote {k}/{N}"Always set ; never fork. Omitting 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.
subagent_typesubagent_typeEach 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 ; you need the final text, not
an async handle. If exceeds ~40, shard into
sequential batches of ~40, but keep each batch a single message.
run_in_backgroundlen(candidates) * NPrompt size at scale. The 3a prompt is ~1200 words. When
, use this compact form instead (same procedure
and output contract, prose stripped):
candidates * votes > ~50Adversarially 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.Findings with a but no get one verifier vote regardless
of (a file-level sweep is expensive and doesn't benefit from
voting).
fileline--votesIf any Task call returns 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:
status: "async_launched"- If completion notifications arrive in your conversation: parse each
verifier's VERDICT block from its notification as it lands. Do not end your turn until every vote is accounted for.
result - 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.
对于中的每个检测结果,构建N个Task调用(N = ,默认3),,。
candidates[]--votessubagent_type: "general-purpose"description: "verify {id} vote {k}/{N}"始终设置;绝不fork。省略会fork编排器,fork会继承完整的对话上下文:每个其他检测结果的描述、扫描器的prose,以及任何先前验证者的结果。这会破坏验证者的独立性,并重新引入此阶段旨在防止的继承框架失败模式。每个验证者必须从全新的空上下文开始,仅接收3a的提示加上正在审查的单个检测结果。同样的规则适用于4a中的排序子代理。
subagent_typesubagent_type每个提示是3a的验证者提示,附加以下块:
────────────────────────────────────────────────────────────────────────
正在审查的检测结果(来自扫描器;视为声明,而非事实):
id: {id}
file: {file}
line: {line}
category: {category}
severity (claimed): {severity}
title: {title}
description:
{description}
exploit_scenario:
{exploit_scenario或"(未提供)"}
preconditions (claimed):
{preconditions作为项目符号或"(未提供)"}
你是第{k}次投票,共{N}次。你未看到其他验证者的推理,也不得尝试查找。独立基于代码工作。将所有验证者Task调用放在一条助手消息中,以便它们并发运行。不要设置;你需要最终文本,而非异步句柄。如果超过40,将其分片为40个的顺序批次,但每个批次保持为一条消息。
run_in_backgroundlen(candidates) * N大规模场景下的提示大小。3a的提示约1200词。当时,改用以下紧凑形式(流程和输出契约相同,去除prose):
candidates * votes > ~50对抗性验证一个扫描器检测结果。默认假设:扫描器错误。
仅允许访问{REPO_PATH}范围内的内容。禁止执行代码、访问网络。
环境:{context.environment}
步骤:(1) 自行阅读{file}:{line};不要信任描述。(2) 反向追踪调用者;引用第一个调用站点的file:line。(3) 寻找防护措施:验证、转义、类型边界、认证网关、死/测试代码。(4) 压力测试每个防护措施在所有路径上的有效性。
排除规则(匹配则为FALSE_POSITIVE):1 容量型DoS;2 测试/死/测试用例代码;3 预期设计;4 安全语言中unsafe/FFI外的内存安全问题;5 仅路径可控的SSRF;6 LLM提示输入;7 对象存储遍历;8 受信任操作员环境/CLI输入;9 客户端代码被标记为服务器端漏洞;10 过时依赖;11 非安全用途的弱随机数;12 低影响nuisance(日志伪造、开放重定向、正则注入);13 仅缺失加固,无具体利用路径;14 自动转义框架中的XSS(无原始HTML出口);15 不可猜测UUID/令牌被标记为可预测;16 仅理论上的竞争/TOCTOU。
{+ 如果有--fp-rules的组织规则,追加在此处}
必须以以下内容结尾:
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, 组织规则,或none>
FIRST_LINK: <file:line或"none found">
RATIONALE: <2-5句话,引用file:line>
检测结果:{id} {file}:{line} {category}(声称{severity})
{title}
{description}
第{k}/{N}次投票。独立工作;不要查看其他投票。有但无的检测结果无论设置如何,仅获得一次验证者投票(文件级扫描成本高,且不会从多投票中受益)。
fileline--votes如果任何Task调用返回而非验证者文本,说明运行时将其后台处理(某些运行时会自动对大型并行批次执行此操作)。选择以下一种恢复方式并应用于整个批次:
status: "async_launched"- 如果对话中收到完成通知:从每个验证者的通知中解析其VERDICT块。直到所有投票都处理完毕才结束回合。
result - 如果未收到通知:不要轮询转录文件。在新的Task批次中重新生成缺失的验证者(更小的分片大小,例如10),并使用同步结果。 同样的恢复方式适用于2b中的去重子代理和4a中的排序子代理。
3c. Tally votes
3c. 统计投票
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 with and
note in . The remaining N-1 votes still
decide.
cannot_verifyconfidence: 0"verifier_error"refute_reasonsBuild:
- :
vote_breakdown{"true_positive": x, "false_positive": y, "cannot_verify": z} - : mean CONFIDENCE across votes that agree with the majority, rounded to one decimal.
confidence - : the modal EXCLUSION_RULE among FALSE_POSITIVE votes, else
exclusion_rule.null - : sorted unique REFUTE_REASON values from FALSE_POSITIVE votes.
refute_reasons - : unique FIRST_LINK values across all votes (reachability audit trail).
first_links - : the RATIONALE from the highest-confidence vote on the winning side, verbatim.
rationale
Decide :
verdict- Majority TRUE_POSITIVE → . Proceeds to Phase 4.
verdict: true_positive - Majority FALSE_POSITIVE → . Skips Phase 4.
verdict: false_positive - No majority (tie, or majority CANNOT_VERIFY):
- Noise tolerance →
precision; appendverdict: false_positiveto rationale."(split vote, dropped under precision policy)" - Noise tolerance →
recallwithverdict: true_positive. Proceeds to Phase 4.verify_verdict: needs_manual_test - Noise tolerance → 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.
ask
- Noise tolerance
Build = candidates with .
confirmed[]verdict == true_positiveCheckpoint: Write tool → :
./.triage-state/_chunk.tmpjson
{"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/_lib/checkpoint.py save ./.triage-state 3 verify --from ./.triage-state/_chunk.tmpThis is the most expensive checkpoint. When exceeds
~40 and verifier spawns are sharded into sequential batches, additionally
checkpoint per candidate as its votes are tallied:
len(candidates) * votes- Write tool → = that finding's post-tally dict.
./.triage-state/_chunk.tmp - Bash:
python3 .claude/skills/_lib/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp
On resume at , the Phase-3 entry point reads
(default — do not glob shard files on
disk; stale shards from a prior run may exist), loads the corresponding
files, and spawns verifiers only for ids
from that are NOT in . Once every candidate is in
, write the consolidated checkpoint as above.
phase_done == 2progress.json:shards_done[]shard_{id}.jsoncandidates[]phase2.jsonshards_doneshards_donephase3.json对于每个候选结果,解析其N个验证者回复末尾的块(容忍代码围栏和空白)。如果验证者出错、超时或未生成可解析的VERDICT块,重新生成一次。如果重试也失败,将该投票计为,,并在中记录。剩余的N-1个投票仍决定结果。
cannot_verifyconfidence: 0refute_reasons"verifier_error"构建:
- :
vote_breakdown{"true_positive": x, "false_positive": y, "cannot_verify": z} - : 与多数意见一致的投票的平均CONFIDENCE,保留一位小数。
confidence - : FALSE_POSITIVE投票中出现次数最多的EXCLUSION_RULE,否则为
exclusion_rule。null - : FALSE_POSITIVE投票中排序后的唯一REFUTE_REASON值。
refute_reasons - : 所有投票中的唯一FIRST_LINK值(可达性审计跟踪)。
first_links - : 获胜方中置信度最高的投票的RATIONALE,逐字保留。
rationale
决定:
verdict- 多数TRUE_POSITIVE → 。进入Phase 4。
verdict: true_positive - 多数FALSE_POSITIVE → 。跳过Phase 4。
verdict: false_positive - 无多数(平局,或多数为CANNOT_VERIFY):
- 噪声容忍度→
precision;在rationale后追加verdict: false_positive。"(投票分歧,根据精确性策略丢弃)" - 噪声容忍度→
recall,并标记verdict: true_positive。进入Phase 4。verify_verdict: needs_manual_test - 噪声容忍度→ 收集所有分歧结果,在Phase 3结束时通过一次AskUserQuestion调用呈现(标题:id + title,选项:保留/丢弃),然后应用用户的选择。
ask
- 噪声容忍度
构建 = 的候选结果。
confirmed[]verdict == true_positive检查点:Write工具 → 写入:
./.triage-state/_chunk.tmpjson
{"phase": 3, "context": {...}, "findings": [ {所有带有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这是成本最高的检查点。当超过~40且验证者生成被分片为顺序批次时,在统计每个候选结果的投票后额外为每个候选结果创建检查点:
len(candidates) * votes- Write工具 → = 该检测结果统计后的字典。
./.triage-state/_chunk.tmp - Bash命令:
python3 .claude/skills/_lib/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp
当从恢复时,Phase 3入口点读取(默认 — 不要全局匹配磁盘上的分片文件;可能存在之前运行的过期分片),加载对应的文件,仅为中不在中的 ID生成验证者。一旦所有候选结果都在中,按上述方式写入合并后的检查点。
phase_done == 2progress.json:shards_done[]shard_{id}.jsonphase2.jsonshards_donecandidates[]shards_donephase3.jsonPhase 4: Rank by exploitability (confirmed findings only)
Phase 4:按可利用性排序(仅针对已确认结果)
Recompute severity from preconditions and reachability rather than 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."
根据前提条件和可达性重新计算严重程度,与扫描器声称的严重程度分开判断。验证和严重程度是独立的判断;“结果真实存在”不能等同于“结果是关键漏洞”。
4a. Ranking prompt
4a. 排序提示
Spawn one Task per confirmed finding (,
all in one message) with:
subagent_type: "general-purpose"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>为每个已确认结果生成一个Task(,全部在一条消息中),提示如下:
subagent_type: "general-purpose"你正在为已确认的安全检测结果分配严重程度。验证已完成;假设结果真实存在。你的唯一工作是推导其严重程度,独立于扫描器的声称。
你可以Read/Grep{REPO_PATH}处的代码库以检查前提条件。禁止执行代码。
环境:{context.environment}
威胁模型(操作员声明,可能为空):
{context.threat_model作为项目符号,或"(未提供)"}
评分标准:{context.scoring}
检测结果:
id: {id}
file: {file}:{line}
category: {category}
claimed severity: {severity}
reachability evidence: {来自Phase 3的first_links}
verifier rationale: {来自Phase 3的rationale}
────────────────────────────────────────────────────────────────────────
步骤1:枚举利用所需的所有前提条件。
具体说明:所需的认证状态、配置、先前请求、竞争窗口、攻击者位置。然后说明所需的最低访问级别(未认证远程/已认证/本地/物理)。
步骤2:根据前提条件数量和访问级别推导严重程度:
| 前提条件数量 | 所需访问级别 | 严重程度 |
|---------------|--------------------------|----------|
| 0 | 未认证远程 | HIGH |
| 1-2 | 已认证 | MEDIUM |
| 3+ | 仅本地/无演示路径 | LOW |
独立评估每一列,取较低的结果。示例:0个前提条件但仅允许已认证访问为MEDIUM,而非HIGH;1个前提条件但仅本地访问为LOW。交叉检查:如果你的前提条件列表有3+项,HIGH几乎肯定是错误的。
步骤3:威胁模型匹配。如果威胁模型非空且该结果与其中一项匹配,注明匹配项。匹配可将严重程度提升一级(LOW到MEDIUM或MEDIUM到HIGH),绝不提升两级。如果威胁模型为空,跳过此步骤。
步骤4:判断扫描器声称的严重程度。从本周已审查200个扫描器结果、对警报疲劳过敏的工程师角度出发:声称的严重程度是否会导致警报疲劳?它是否与该级别的真实CVE相当?代码是否在测试用例或仅开发环境配置中?评分范围-5..+5:
+3..+5 声称的严重程度合理或被低估
0..+2 大致正确
-1..-3 被高估一级
-4..-5 严重高估(LOW伪装成HIGH)
步骤5:verify_verdict。必须是以下之一:
exploitable 前提条件可真实满足
mitigated 结果真实存在,但已部署的控制措施将其降低到推导的严重程度以下(注明控制措施)
needs_manual_test 严重程度取决于仅运行时测试才能确定的因素;建议人工构建PoC
步骤6:如果评分标准是CVSS或OWASP变体,以该格式输出`severity_label`(CVSS为向量字符串+基础分数;OWASP为可能性×影响)。否则将其设置为推导的HIGH/MEDIUM/LOW。
────────────────────────────────────────────────────────────────────────
仅返回以下块:
PRECONDITIONS:
- <每行一个>
ACCESS_LEVEL: <unauthenticated_remote|authenticated|local|physical>
SEVERITY: <HIGH|MEDIUM|LOW>
SEVERITY_LABEL: <符合评分标准>
THREAT_MATCH: <匹配的威胁模型条目,或none>
SEVERITY_ALIGNMENT: <-5..+5>
VERIFY_VERDICT: <exploitable|mitigated|needs_manual_test>
RANK_RATIONALE: <2-4句话>4b. Merge
4b. 合并结果
For each confirmed finding, parse the block and attach
(replacing any scanner-supplied list), ,
(recomputed), , , ,
, and append RANK_RATIONALE to (separated by a
blank line from the Phase-3 rationale).
preconditionsaccess_levelseverityseverity_labelthreat_matchseverity_alignmentverify_verdictrationaleFor findings that did NOT reach Phase 4 (, ,
unlocatable): set , ,
, .
false_positiveduplicateseverity: nullverify_verdict: nullseverity_alignment: nullpreconditions: []Checkpoint: Write tool → :
./.triage-state/_chunk.tmpjson
{"phase": 4, "context": {...}, "findings": [ {all findings with severity/severity_label/preconditions/access_level/threat_match/severity_alignment/verify_verdict} ]}Then Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 4 rank --from ./.triage-state/_chunk.tmp对于每个已确认结果,解析上述块并附加(替换任何扫描器提供的列表)、、(重新计算)、、、、,并将RANK_RATIONALE追加到中(与Phase 3的rationale之间用空行分隔)。
preconditionsaccess_levelseverityseverity_labelthreat_matchseverity_alignmentverify_verdictrationale对于未进入Phase 4的结果(、、无法定位):设置、、、。
false_positiveduplicateseverity: nullverify_verdict: nullseverity_alignment: nullpreconditions: []检查点:Write工具 → 写入:
./.triage-state/_chunk.tmpjson
{"phase": 4, "context": {...}, "findings": [ {所有带有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.tmpPhase 5: Route
Phase 5:分配责任人
Tag each confirmed true-positive with the most specific component or owner
inferable. For each finding in , stop at the first hit:
confirmed[]- CODEOWNERS / OWNERS. Grep for
--repo,CODEOWNERS,OWNERS,.github/CODEOWNERS. If found, match the finding'sdocs/CODEOWNERSagainst its patterns (last match wins). Hint:file."CODEOWNERS: <pattern> -> <owner(s)>" - git log. If is a git checkout, run
--repo. Hint: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" - Module fallback. Hint: .
"component: <top-level dir of file>/; no CODEOWNERS or git history"
Attach as . State the source so confidence is clear; a bare
username is less useful than . For non-true-positive findings,
set .
owner_hint"component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)"owner_hint: nullCheckpoint: Write tool → :
./.triage-state/_chunk.tmpjson
{"phase": 5, "context": {...}, "findings": [ {all findings with owner_hint} ]}Then Bash:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmp为每个已确认的true-positive标记可推断的最具体组件或责任人。对于中的每个结果,找到第一个匹配项后停止:
confirmed[]- CODEOWNERS / OWNERS。在中Grep查找
--repo、CODEOWNERS、OWNERS、.github/CODEOWNERS。如果找到,将结果的docs/CODEOWNERS与其模式匹配(最后匹配的结果生效)。提示:file。"CODEOWNERS: <模式> -> <责任人>" - git log。如果是git checkout,执行
--repo。提示:git -C {REPO} log --format='%an' -n 50 -- "{file}" | sort | uniq -c | sort -rn | head -3。"top committer: <姓名> (<n>/<total> recent commits); no CODEOWNERS entry" - 模块回退。提示:。
"component: <文件的顶级目录>/; no CODEOWNERS or git history"
将结果附加为。注明来源以明确置信度;仅用户名不如有用。对于非true-positive结果,设置。
owner_hint"component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)"owner_hint: null检查点:Write工具 → 写入:
./.triage-state/_chunk.tmpjson
{"phase": 5, "context": {...}, "findings": [ {所有带有owner_hint的检测结果} ]} 然后执行Bash命令:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmpPhase 6: Output
Phase 6:输出
6a. Sort
6a. 排序
Order all findings by:
- :
verdict, thentrue_positive, thenduplicate.false_positive - Within true positives: HIGH > MEDIUM > LOW, then
severitydescending, thenconfidencedescending.severity_alignment - Within others: original .
id
所有检测结果按以下顺序排序:
- :
verdict,然后true_positive,然后duplicate。false_positive - 在true-positive中:HIGH > MEDIUM > LOW,然后
severity降序,然后confidence降序。severity_alignment - 在其他结果中:原始顺序。
id
6b. Write ./TRIAGE.json
./TRIAGE.json6b. 写入./TRIAGE.json
./TRIAGE.jsonjson
{
"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": ["..."]
}
]
}Every input finding appears exactly once (duplicates reference their
canonical via ). Do not silently drop anything. Do not print
this JSON to the terminal; write to file only.
duplicate_ofjson
{
"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,6c. Write ./TRIAGE.md
./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 → (clobbers any prior file)
containing only the title block, summary, and heading:
./TRIAGE.md## Act on theseundefined—
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}Recommend a human build a PoC; static reasoning hit its limit.
2. Bash:
`python3 .claude/skills/_lib/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/_lib/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—
Testing this skill
—
Smoke test (five-finding fixture: 2 real, 1 dup, 2 FP):
/triage .claude/skills/triage/fixtures/canary-findings.json --auto --repo targets/canaryExpected: f001 and f003 confirmed; f002 duplicate of f001; f004 dropped
(: it's a read buffer, not a randomness source); f005 dropped
(: there is a null check at line 68).
misread_codealready_handledOr against pipeline output:
vuln-pipeline run drlibs --runs 3 --parallel --stream
/triage results/drlibs/<ts>/ --repo targets/drlibsHand-check a sample of TRUE_POSITIVE/HIGH results (the should
point at real call sites) and a sample of FALSE_POSITIVE rejects (the
or should be defensible).
first_linksexclusion_rulerefute_reasons—
Design notes
—
- Checkpoints are per-phase JSON, not conversation state. The pipeline's
(docs/pipeline.md) 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.
--resume <session_id>is scratch — add to./.triage-state/..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 (owner hints),
git log/jq(ingest), andfind(state I/O). The actual safety property is "no execution of target code," which is preserved.python3 .claude/skills/_lib/checkpoint.py - exists so verifiers aren't forced into a false binary. It maps to
CANNOT_VERIFYunder recall policy and to a drop under precision policy.needs_manual_test - Threat-model boost is capped at one step so a stated threat can't re-inflate a LOW back to HIGH and defeat the precondition rule.
- is separate from
severity_label. Sorting always uses the precondition-derived HIGH/MEDIUM/LOW; the label is presentation-layer for whatever standard the reviewer's tooling expects.severity - Pipeline 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
report.jsonverdicts on that input than on static-scanner JSON.needs_manual_test - 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.
—