triage

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

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

对原始安全扫描器输出进行对抗性分类处理,完成四项工作:验证每个检测结果是否真实存在,去重跨扫描运行和不同扫描器的重复结果,排序剩余结果(基于推导的可利用性而非扫描器声称的严重程度),以及分配每个结果到对应组件责任人。输出是一个简短、已排序且标记责任人的列表,而非原始扫描输出。
调用方式:
/triage <检测结果路径> [--auto] [--votes N] [--repo PATH] [--fp-rules FILE]
参数(从
$ARGUMENTS
解析;位置参数
$1
/
$2
的扩展在不同运行时不稳定):
  • 检测结果路径(第一个位置参数,必填):JSON文件、JSON文件目录、
    VULN-FINDINGS.json
    、流水线
    results/<target>/<ts>/
    目录,或markdown报告。
  • --auto
    :跳过交互问答,使用默认设置。默认模式为交互模式
  • --votes N
    :每个检测结果的验证者投票数(默认3;快速检查用1,高风险批次用5)。
  • --repo PATH
    :目标代码库的路径,只读(默认当前工作目录)。验证需要访问源代码;如果引用的文件无法访问,该工具会报错停止。
  • --fp-rules FILE
    :将FILE的内容追加到验证者的排除规则列表(Phase 3a)。用于组织特定先例:“我们全程使用Prisma ORM — 仅原生查询存在SQL注入风险”“k8s资源限制覆盖拒绝服务风险”等。纯文本格式,每条规则占一行或一段。
  • --fresh
    :忽略
    ./.triage-state/
    中任何现有检查点,从Phase 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数据库或获取上游提交记录。

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
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 3,会生成
候选数 × 投票数
个验证者。阶段状态会持久化到
./.triage-state/
,因此新的
/triage
会话可以从最后完成的阶段恢复,无需重新进行交互问答或重新生成验证者。
所有检查点I/O都通过
python3 .claude/skills/_lib/checkpoint.py
完成(原子写入,JSON验证)。禁止直接使用Write工具操作
progress.json
。禁止通过 heredoc 或标准输入传递负载;目标派生的字符串可能与heredoc分隔符冲突,导致shell逃逸。Write→
--from
模式可避免将代码库派生的字节传入Bash参数。
./.triage-state/
中的状态文件:
  • progress.json
    — 恢复位置的唯一可信来源
    {"status": "running"|"complete", "phase_done": N, "shards_done": [...]}
    。恢复决策仅读取此文件,绝不读取
    phase*.json
    的全局匹配或分片文件(之前运行的过期文件不可信)。
  • phaseN.json
    — Phase N的数据负载(每个阶段部分末尾有对应的 schema)。
  • _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
每个阶段N结束时。执行两个工具调用:
  1. Write工具 → 将阶段输出JSON写入
    ./.triage-state/_chunk.tmp
    (每个阶段部分末尾有对应的schema)。
  2. 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: Mode select and interview

Phase 0:模式选择与交互问答

0a. Parse arguments

0a. 解析参数

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.
$ARGUMENTS
中提取:检测结果路径(第一个位置参数)、
--auto
标志、
--votes N
(默认3)、
--repo PATH
(默认
.
)、
--fp-rules FILE
(默认无)。如果未提供检测结果路径,询问用户并停止。如果提供了
--fp-rules
,立即读取该文件,并将内容作为
context.extra_fp_rules
带入Phase 3a的验证者提示中。

0b. Interactive mode (default): interview the user

0b. 交互模式(默认):与用户交互

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
.
除非传入
--auto
,否则使用AskUserQuestion收集影响验证和排序的上下文信息。将问题分批为1-2次调用,每次最多4个问题。接受“其他”选项下的自由文本回答;多选选项仅作为提示,而非约束。
第一轮(单次AskUserQuestion调用):
  1. 环境与信任边界(标题
    Environment
    ,单选)
    这些检测结果来自哪种系统,不受信任的输入从何处进入系统?
    选项:
    面向互联网的Web服务(HTTP为不受信任来源)
    内部服务(调用者为已认证的对等方)
    库/SDK(调用者为信任边界)
    CLI/批处理工具(操作员输入可信,文件输入不可信)
    嵌入式/固件(物理访问在范围内)
    。 可达性基于此边界判断;“环境变量导致命令注入”在多租户Web服务中是真阳性,而在操作员CLI中是规则8下的假阳性。
  2. 威胁模型(标题
    Threat model
    ,多选)
    该系统的最坏情况攻击者是什么样的,绝对不能发生的情况是什么?最好使用自由文本回答。
    选项:
    未认证远程代码执行
    租户间数据泄露
    权限提升至管理员
    下游用户供应链受损
    违反付费SLA的拒绝服务
    合规范围内的数据泄露(PII/PCI/PHI)
    。 Phase 4会提升与声明威胁匹配的结果优先级。
  3. 评分标准(标题
    Scoring
    ,单选)
    输出中应如何表示严重程度?
    选项:
    从前提条件推导HIGH/MEDIUM/LOW(默认)
    CVSS v3.1向量 + 基础分数
    CVSS v4.0向量 + 基础分数
    OWASP风险评级(可能性×影响)
    组织漏洞阈值(在“其他”中描述)
    。 前提条件规则始终会计算;此选项控制
    severity_label
    额外显示的内容。
  4. 噪声容忍度(标题
    Noise tolerance
    ,单选)
    当验证者意见不一致时,平局应如何处理?
    选项:
    精确性:丢弃所有未获得多数确认的结果(假阳性更少,可能遗漏真实漏洞)
    召回率:将分歧投票标记为needs_manual_test(需审查的内容更多,遗漏更少)
    每个结果出现分歧时询问我
第二轮(条件触发):如果威胁模型回答为空或通用,或评分回答为
组织漏洞阈值
,则询问一个针对性的后续问题。
将答案记录为
context
字典,带入每个阶段,并在输出的
triage_context
中回显。

0c. Auto mode defaults

0c. 自动模式默认值

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.

当设置
--auto
时,不调用AskUserQuestion。使用以下默认值:
  • 环境:
    未知。将任何外部可达的入口点视为不受信任;在理由中明确标记信任边界假设。
  • 威胁模型:空(无优先级提升)。
  • 评分:推导HIGH/MEDIUM/LOW。
  • 噪声容忍度:精确性。
检查点:Write工具 → 写入
./.triage-state/_chunk.tmp
json
{"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
恢复到Phase 0之后的运行时,不会重新进行交互问答;
context
从此文件恢复。

Phase 1: Ingest and normalize

Phase 1:导入与标准化

Turn the input into a flat
findings[]
list with stable ids, regardless of source format.
将输入转换为具有稳定ID的扁平
findings[]
列表,无论源格式如何。

1a. Detect input shape

1a. 检测输入格式

Inspect the findings path:
  • Directory: Glob for
    **/*.json
    and
    **/*.jsonl
    . Recognized containers, in priority order:
    • VULN-FINDINGS.json
      (a
      {findings: [...]}
      container): read
      .findings[]
      .
    • reports/bug_*/report.json
      or
      reports/manifest.jsonl
      (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.
检查检测结果路径:
  • 目录:全局匹配
    **/*.json
    **/*.jsonl
    。按优先级识别以下容器:
    • VULN-FINDINGS.json
      {findings: [...]}
      容器):读取
      .findings[]
    • reports/bug_*/report.json
      reports/manifest.jsonl
      (此仓库的流水线输出):每个
      bug_NN
      对应一个检测结果。将
      crash.crash_type
      映射为
      category
      verdict.severity_rating
      映射为
      severity
      , prose形式的
      report
      映射为
      description
      ,ASAN栈顶帧的崩溃文件映射为
      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):
CanonicalAlso 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.
对于每个原始记录,构建检测结果字典。提取存在的字段;绝不猜测缺失的字段。字段映射(源键别名 → 规范键):
规范键可接受的别名
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
    ,则按该字段降序导入,以便高信号结果优先验证(并在部分输出中显示);否则保持源顺序。这仅为调度优先级 — 不影响 verdict。
  • source
    :文件的相对路径,加上源格式。
  • missing_fields
    :缺失的规范字段列表。如果
    file
    缺失或在
    --repo
    下无法解析,该检测结果为无法定位:跳过去重和验证,直接输出
    verdict: false_positive
    verify_verdict: needs_manual_test
    confidence: 0
    refute_reasons: ["doesnt_exist"]
    rationale: "输入中无源代码位置;无法静态验证;需人工审查"
    。绝不对无法定位的检测结果给出可信 verdict,也绝不允许其参与去重合并或被合并。

1c. Locate the target codebase

1c. 定位目标代码库

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
{"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

解析
--repo
(默认当前工作目录)。对于前5个包含
file
的检测结果,检查路径是否在代码库下可解析。按以下顺序尝试:(a) 按给定路径
repo/file
;(b)
file
作为绝对路径或当前工作目录相对路径;(c) 从
file
中去除常见前缀(
src/
app/
./
或代码库自身的basename,例如
--repo harness
时的
harness/grade.py
)后的
repo/file
。记录有效的解析方式,并应用于所有检测结果。如果均无法解析,停止:告知用户验证需要源代码访问,且引用的文件无法访问,并根据所见的最长公共后缀建议
--repo
值。
检查点:Write工具 → 写入
./.triage-state/_chunk.tmp
json
{"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.tmp

Phase 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
    file
    (after path normalization), AND
  • same
    category
    (case-insensitive, punctuation stripped), AND
  • line
    numbers within 10 of each other. Both-missing matches; one-side- missing does NOT (a line-less record must not absorb a located one).
Within each cluster, the canonical is the record with the fewest
missing_fields
; ties break to lowest
id
. Every other member gets
verdict: duplicate
,
duplicate_of: <canonical id>
, and is removed from the working set. Record duplicate ids on the canonical as
absorbed: [...]
.
将满足以下所有条件的检测结果聚类:
  • 相同
    file
    (路径标准化后),且
  • 相同
    category
    (不区分大小写,去除标点),且
  • line
    编号相差在10以内。双方都缺失line时匹配;仅一方缺失时不匹配(无line的记录不能合并有line的记录)。
在每个聚类中,规范记录为
missing_fields
最少的记录;平局时选择ID最小的记录。其他成员标记为
verdict: duplicate
duplicate_of: <规范ID>
,并从工作集中移除。在规范记录的
absorbed: [...]
中记录重复ID。

2b. Semantic pass (one subagent, only if >1 cluster survives)

2b. 语义去重(一个子代理,仅当>1个聚类存活时)

Spawn ONE Task with
subagent_type: "general-purpose"
and this prompt:
You are deduplicating security findings before expensive verification. Two
findings are DUPLICATES if fixing one would also fix the other. Two findings
are DISTINCT if they have genuinely independent root causes, even if they
share a category or file.

Treat as DUPLICATE:
- Same root cause described with different wording or by different scanners
- A shared vulnerable helper function reported once per call site
- A missing global protection (auth check, output encoding) reported once
  per endpoint that lacks it
- A cause ("missing input validation on `name`") and its consequence
  ("SQL injection via `name`") in the same code path

Treat as DISTINCT:
- Different categories in the same file region (an "ssrf" near a
  "buffer_overflow" is not a duplicate just because the lines are close)
- Same file, same category, but different tainted variables reaching
  different sinks
- Same helper, but two independent bugs inside it
- Two endpoints missing the same check, where the fix is per-endpoint
  rather than a shared gate

Below are the candidate findings (one per line: id | file:line | category |
title). Group them. Respond with ONLY lines of the form:

  GROUP: <canonical_id> <- <dup_id>, <dup_id>, ...

One line per group that has duplicates. Omit singletons. Pick the most
specific / best-described finding as canonical. No prose.

CANDIDATES:
{one line per surviving finding: "f003 | src/auth.py:112 | sql_injection | User lookup concatenates name into query"}
Parse
GROUP:
lines. For each, mark the listed dup ids with
verdict: duplicate
,
duplicate_of: <canonical>
, append them to the canonical's
absorbed
, and drop them from the working set.
Carry forward
candidates[]
= the surviving canonicals.
Checkpoint: Write tool →
./.triage-state/_chunk.tmp
:
json
{"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语句"} 
解析
GROUP:
行。对于每行,将列出的重复ID标记为
verdict: duplicate
duplicate_of: <规范ID>
,将它们追加到规范记录的
absorbed
中,并从工作集中移除。
candidates[]
设为存活的规范记录。
检查点:Write工具 → 写入
./.triage-state/_chunk.tmp
json
{"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.tmp

Phase 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
candidates[]
, build N Task calls (N =
--votes
, default 3) with
subagent_type: "general-purpose"
and
description: "verify {id} vote {k}/{N}"
.
Always set
subagent_type
; never fork.
Omitting
subagent_type
forks the orchestrator, and a fork inherits the full conversation context: every other finding's description, the scanner's prose, and any prior verifier results. That defeats verifier independence and re-introduces the inherited-framing failure mode this phase exists to prevent. Each verifier must start with a fresh, empty context and receive only the 3a prompt plus the single finding under review. The same applies to the ranking subagents in 4a.
Each prompt is the verifier prompt from 3a with this block appended:
────────────────────────────────────────────────────────────────────────
FINDING UNDER REVIEW (from the scanner; treat as a CLAIM, not a fact):

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

  description:
  {description}

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

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

You are vote {k} of {N}. You have NOT seen the other verifiers' reasoning
and you must NOT try to find it. Work independently from the code.
Put all verifier Task calls in a single assistant message so they run concurrently. Do not set
run_in_background
; you need the final text, not an async handle. If
len(candidates) * N
exceeds ~40, shard into sequential batches of ~40, but keep each batch a single message.
Prompt size at scale. The 3a prompt is ~1200 words. When
candidates * votes > ~50
, use this compact form instead (same procedure and output contract, prose stripped):
Adversarially verify ONE scanner finding. Default: scanner is WRONG.
Read-only access scoped to {REPO_PATH} ONLY. No exec, no network.
ENVIRONMENT: {context.environment}

Steps: (1) Read {file}:{line} yourself; don't trust the description.
(2) Trace callers backwards; quote the first call-site file:line.
(3) Hunt for protections: validation, escaping, type bounds, auth gates,
dead/test code. (4) Stress-test each protection on every path.

Exclusion rules (FALSE_POSITIVE if matched): 1 volumetric DoS;
2 test/dead/fixture code; 3 intended design; 4 memory-safety in safe
lang outside unsafe/FFI; 5 SSRF path-only; 6 LLM prompt input;
7 object-storage traversal; 8 trusted operator env/CLI inputs;
9 client code, server vuln class; 10 outdated deps; 11 weak random
non-security; 12 low-impact nuisance (log spoof, open redirect, regex
inject); 13 missing-hardening-only, no 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
file
but no
line
get one verifier vote regardless of
--votes
(a file-level sweep is expensive and doesn't benefit from voting).
If any Task call returns
status: "async_launched"
instead of the verifier's text
, the runtime backgrounded it (some runtimes do this automatically for large parallel batches). Pick one recovery and use it for the whole batch:
  • If completion notifications arrive in your conversation: parse each verifier's VERDICT block from its notification
    result
    as it lands. Do not end your turn until every vote is accounted for.
  • If notifications do not arrive: do not poll transcript files. Re-spawn the missing verifiers in a fresh Task batch (smaller shard size, e.g. 10) and use the synchronous results. The same recovery applies to the dedupe subagent in 2b and the ranking subagents in 4a.
对于
candidates[]
中的每个检测结果,构建N个Task调用(N =
--votes
,默认3),
subagent_type: "general-purpose"
description: "verify {id} vote {k}/{N}"
始终设置
subagent_type
;绝不fork
。省略
subagent_type
会fork编排器,fork会继承完整的对话上下文:每个其他检测结果的描述、扫描器的prose,以及任何先前验证者的结果。这会破坏验证者的独立性,并重新引入此阶段旨在防止的继承框架失败模式。每个验证者必须从全新的空上下文开始,仅接收3a的提示加上正在审查的单个检测结果。同样的规则适用于4a中的排序子代理。
每个提示是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调用放在一条助手消息中,以便它们并发运行。不要设置
run_in_background
;你需要最终文本,而非异步句柄。如果
len(candidates) * N
超过40,将其分片为40个的顺序批次,但每个批次保持为一条消息。
大规模场景下的提示大小。3a的提示约1200词。当
candidates * votes > ~50
时,改用以下紧凑形式(流程和输出契约相同,去除prose):
对抗性验证一个扫描器检测结果。默认假设:扫描器错误。
仅允许访问{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}次投票。独立工作;不要查看其他投票。
file
但无
line
的检测结果无论
--votes
设置如何,仅获得一次验证者投票(文件级扫描成本高,且不会从多投票中受益)。
如果任何Task调用返回
status: "async_launched"
而非验证者文本
,说明运行时将其后台处理(某些运行时会自动对大型并行批次执行此操作)。选择以下一种恢复方式并应用于整个批次:
  • 如果对话中收到完成通知:从每个验证者的通知
    result
    中解析其VERDICT块。直到所有投票都处理完毕才结束回合。
  • 如果未收到通知:不要轮询转录文件。在新的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
cannot_verify
with
confidence: 0
and note
"verifier_error"
in
refute_reasons
. The remaining N-1 votes still decide.
Build:
  • vote_breakdown
    :
    {"true_positive": x, "false_positive": y, "cannot_verify": z}
  • confidence
    : mean CONFIDENCE across votes that agree with the majority, rounded to one decimal.
  • exclusion_rule
    : the modal EXCLUSION_RULE among FALSE_POSITIVE votes, else
    null
    .
  • refute_reasons
    : sorted unique REFUTE_REASON values from FALSE_POSITIVE votes.
  • first_links
    : unique FIRST_LINK values across all votes (reachability audit trail).
  • rationale
    : the RATIONALE from the highest-confidence vote on the winning side, verbatim.
Decide
verdict
:
  • Majority TRUE_POSITIVE →
    verdict: true_positive
    . Proceeds to Phase 4.
  • Majority FALSE_POSITIVE →
    verdict: false_positive
    . Skips Phase 4.
  • No majority (tie, or majority CANNOT_VERIFY):
    • Noise tolerance
      precision
      verdict: false_positive
      ; append
      "(split vote, dropped under precision policy)"
      to rationale.
    • Noise tolerance
      recall
      verdict: true_positive
      with
      verify_verdict: needs_manual_test
      . Proceeds to Phase 4.
    • Noise tolerance
      ask
      → collect all split findings and present them in one AskUserQuestion call at the end of Phase 3 (header: id + title, options: keep / drop), then apply the user's choices.
Build
confirmed[]
= candidates with
verdict == true_positive
.
Checkpoint: Write tool →
./.triage-state/_chunk.tmp
:
json
{"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.tmp
This is the most expensive checkpoint. When
len(candidates) * votes
exceeds ~40 and verifier spawns are sharded into sequential batches, additionally checkpoint per candidate as its votes are tallied:
  1. Write tool →
    ./.triage-state/_chunk.tmp
    = that finding's post-tally dict.
  2. Bash:
    python3 .claude/skills/_lib/checkpoint.py shard ./.triage-state <id> --from ./.triage-state/_chunk.tmp
On resume at
phase_done == 2
, the Phase-3 entry point reads
progress.json:shards_done
(default
[]
— do not glob shard files on disk; stale shards from a prior run may exist), loads the corresponding
shard_{id}.json
files, and spawns verifiers only for
candidates[]
ids from
phase2.json
that are NOT in
shards_done
. Once every candidate is in
shards_done
, write the consolidated
phase3.json
checkpoint as above.

对于每个候选结果,解析其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,保留一位小数。
  • 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后追加
      "(投票分歧,根据精确性策略丢弃)"
    • 噪声容忍度
      recall
      verdict: true_positive
      ,并标记
      verify_verdict: needs_manual_test
      。进入Phase 4。
    • 噪声容忍度
      ask
      → 收集所有分歧结果,在Phase 3结束时通过一次AskUserQuestion调用呈现(标题:id + title,选项:保留/丢弃),然后应用用户的选择。
构建
confirmed[]
=
verdict == true_positive
的候选结果。
检查点:Write工具 → 写入
./.triage-state/_chunk.tmp
json
{"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
这是成本最高的检查点。当
len(candidates) * votes
超过~40且验证者生成被分片为顺序批次时,在统计每个候选结果的投票后额外为每个候选结果创建检查点
  1. Write工具 →
    ./.triage-state/_chunk.tmp
    = 该检测结果统计后的字典。
  2. 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
(默认
[]
— 不要全局匹配磁盘上的分片文件;可能存在之前运行的过期分片),加载对应的
shard_{id}.json
文件,仅为
phase2.json
中不在
shards_done
中的
candidates[]
ID生成验证者。一旦所有候选结果都在
shards_done
中,按上述方式写入合并后的
phase3.json
检查点。

Phase 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 (
subagent_type: "general-purpose"
, all in one message) with:
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
preconditions
(replacing any scanner-supplied list),
access_level
,
severity
(recomputed),
severity_label
,
threat_match
,
severity_alignment
,
verify_verdict
, and append RANK_RATIONALE to
rationale
(separated by a blank line from the Phase-3 rationale).
For findings that did NOT reach Phase 4 (
false_positive
,
duplicate
, unlocatable): set
severity: null
,
verify_verdict: null
,
severity_alignment: null
,
preconditions: []
.
Checkpoint: Write tool →
./.triage-state/_chunk.tmp
:
json
{"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

对于每个已确认结果,解析上述块并附加
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
json
{"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.tmp

Phase 5: Route

Phase 5:分配责任人

Tag each confirmed true-positive with the most specific component or owner inferable. For each finding in
confirmed[]
, stop at the first hit:
  1. CODEOWNERS / OWNERS. Grep
    --repo
    for
    CODEOWNERS
    ,
    OWNERS
    ,
    .github/CODEOWNERS
    ,
    docs/CODEOWNERS
    . If found, match the finding's
    file
    against its patterns (last match wins). Hint:
    "CODEOWNERS: <pattern> -> <owner(s)>"
    .
  2. git log. If
    --repo
    is a git checkout, run
    git -C {REPO} log --format='%an' -n 50 -- "{file}" | sort | uniq -c | sort -rn | head -3
    . Hint:
    "top committer: <name> (<n>/<total> recent commits); no CODEOWNERS entry"
    .
  3. Module fallback. Hint:
    "component: <top-level dir of file>/; no CODEOWNERS or git history"
    .
Attach as
owner_hint
. State the source so confidence is clear; a bare username is less useful than
"component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)"
. For non-true-positive findings, set
owner_hint: null
.
Checkpoint: Write tool →
./.triage-state/_chunk.tmp
:
json
{"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[]
中的每个结果,找到第一个匹配项后停止:
  1. CODEOWNERS / OWNERS。在
    --repo
    中Grep查找
    CODEOWNERS
    OWNERS
    .github/CODEOWNERS
    docs/CODEOWNERS
    。如果找到,将结果的
    file
    与其模式匹配(最后匹配的结果生效)。提示:
    "CODEOWNERS: <模式> -> <责任人>"
  2. git log。如果
    --repo
    是git checkout,执行
    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"
  3. 模块回退。提示:
    "component: <文件的顶级目录>/; no CODEOWNERS or git history"
将结果附加为
owner_hint
。注明来源以明确置信度;仅用户名不如
"component: auth/; no CODEOWNERS entry; top committer jsmith (14/20 recent commits)"
有用。对于非true-positive结果,设置
owner_hint: null
检查点:Write工具 → 写入
./.triage-state/_chunk.tmp
json
{"phase": 5, "context": {...}, "findings": [ {所有带有owner_hint的检测结果} ]} 
然后执行Bash命令:
python3 .claude/skills/_lib/checkpoint.py save ./.triage-state 5 route --from ./.triage-state/_chunk.tmp

Phase 6: Output

Phase 6:输出

6a. Sort

6a. 排序

Order all findings by:
  1. verdict
    :
    true_positive
    , then
    duplicate
    , then
    false_positive
    .
  2. Within true positives:
    severity
    HIGH > MEDIUM > LOW, then
    confidence
    descending, then
    severity_alignment
    descending.
  3. Within others: original
    id
    .
所有检测结果按以下顺序排序:
  1. verdict
    :
    true_positive
    ,然后
    duplicate
    ,然后
    false_positive
  2. 在true-positive中:
    severity
    HIGH > MEDIUM > LOW,然后
    confidence
    降序,然后
    severity_alignment
    降序。
  3. 在其他结果中:原始
    id
    顺序。

6b. Write
./TRIAGE.json

6b. 写入
./TRIAGE.json

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": ["..."]
    }
  ]
}
Every input finding appears exactly once (duplicates reference their canonical via
duplicate_of
). Do not silently drop anything. Do not print this JSON to the terminal; write to file only.
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,

6c. Write
./TRIAGE.md

Reviewer-facing report. Build it incrementally. Do NOT emit the whole file in one Write. One chunk per finding; a stalled chunk loses that one section, not the file.
Step 1 — header. Write tool →
./TRIAGE.md
(clobbers any prior file) containing only the title block, summary, and
## Act on these
heading:
undefined

Triage Report

{summary line: N in -> D duplicates, F false positives, T confirmed (H high / M med / L low), X need manual test}
Context: {mode}; environment = {environment}; scoring = {scoring}; {votes}-vote verification.

Act on these


**Step 2 — per finding.** For each true_positive in severity order:
1. Write tool → `./.triage-state/_chunk.tmp` containing ONE finding's section:

[{severity}] {title} ({id})

{file}:{line}
| {category} | claimed {claimed_severity} (alignment {severity_alignment:+d}) | confidence {confidence}/10 Owner: {owner_hint} Verdict: {verify_verdict}, votes {vote_breakdown} 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.

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/canary
Expected: f001 and f003 confirmed; f002 duplicate of f001; f004 dropped (
misread_code
: it's a read buffer, not a randomness source); f005 dropped (
already_handled
: there is a null check at line 68).
Or against pipeline output:
vuln-pipeline run drlibs --runs 3 --parallel --stream
/triage results/drlibs/<ts>/ --repo targets/drlibs
Hand-check a sample of TRUE_POSITIVE/HIGH results (the
first_links
should point at real call sites) and a sample of FALSE_POSITIVE rejects (the
exclusion_rule
or
refute_reasons
should be defensible).

Design notes

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