This is an automated email from the ASF dual-hosted git repository.
potiuk pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git
The following commit(s) were added to refs/heads/main by this push:
new d83fe40 feat(security-issue-triage,import): add FIX-ALREADY-PUBLIC
disposition (#214)
d83fe40 is described below
commit d83fe40ce93b0ec63ef737e25473397ccabb2dc9
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon May 18 20:00:16 2026 +0200
feat(security-issue-triage,import): add FIX-ALREADY-PUBLIC disposition
(#214)
When a `<security-list>` report arrives describing behaviour that an
independent public PR in `<upstream>` already appears to fix, the
project's existing policy (inherited from `security-issue-import-from-pr`)
applies: thank the reporter, do not award finder credit, point at the
PR, and ask them to verify whether it addresses what they reported.
Previously the skills had no handle for this case — triage would
classify the resulting tracker into one of the existing five classes
and the no-credit policy was easy to miss; worse, `security-issue-import`
would default to creating a tracker that was destined to be closed.
Two-layer fix:
- `security-issue-import` gains Step 2c (search `<upstream>` for an
already-public fix), a new `fix-already-public` classification that
does NOT default to import, an explicit reply shape (thank without
credit + verify-with-PR + come-back-if-not-fixed), and a
`NN:reject-with-public-fix <PR-URL>` user override for cases the
automatic detection misses. Step 7 drafts the Gmail reply but
creates no tracker; the PR stays unaware of the private report
(no-outreach posture mirrored from security-issue-import-from-pr).
- `security-issue-triage` gains FIX-ALREADY-PUBLIC as the sixth
disposition class for the safety-net case where a tracker was
imported before the public PR was noticed. The proposal cites the
PR, drafts a reporter-reply template, and routes to
`/security-issue-invalidate` after the reporter confirms (or to
`--retriage` if they say the PR does not fix it).
Docs updated: docs/security/process.md, docs/security/README.md, and
AGENTS.md to reflect the new class and its routing.
Validated via prek run --all-files (clean) and skill-validate (clean).
Generated-by: Claude Code (Opus 4.7)
---
.claude/skills/security-issue-import/SKILL.md | 208 ++++++++++++++++++++++++--
.claude/skills/security-issue-triage/SKILL.md | 118 +++++++++++++--
AGENTS.md | 6 +-
docs/security/README.md | 2 +-
docs/security/process.md | 5 +-
5 files changed, 316 insertions(+), 23 deletions(-)
diff --git a/.claude/skills/security-issue-import/SKILL.md
b/.claude/skills/security-issue-import/SKILL.md
index ebdb931..2abe7c0 100644
--- a/.claude/skills/security-issue-import/SKILL.md
+++ b/.claude/skills/security-issue-import/SKILL.md
@@ -830,6 +830,121 @@ from the canned-responses file, not by pasting prior
outbound mail.
---
+## Step 2c — Search `<upstream>` for an already-public fix
+
+Step 2a finds existing *trackers* that overlap. Step 2b finds
+*prior reports* that were rejected. Step 2c covers a third
+no-tracker-needed case: an **independent public PR in `<upstream>`
+already appears to fix the reported behaviour**. The reporter sent
+`<security-list>` without knowing the fix landed (or is in flight);
+opening a tracker would create a redundant audit-trail entry and
+later force the team through `security-issue-invalidate` to close
+it. Catching the case at import time is cheaper: thank the reporter,
+point at the PR, ask them to verify, and skip tracker creation.
+
+**Run Step 2c on** every `Report` / `ASF-security relay` candidate
+that Step 2a did *not* flag STRONG (STRONG-dedup routes to
+`security-issue-deduplicate`, which already handles the
+already-tracked case). Skip on `automated-scanner`,
+`consolidated-multi-issue`, `media-request`, `spam`,
+`cve-tool-bookkeeping`, and `cross-thread-followup` candidates —
+those never become trackers regardless.
+
+**Detection signals** (any one is sufficient to surface the
+candidate as a potential `fix-already-public`):
+
+1. **Reporter links to a public PR.** The body contains an
+ `https://github.com/<upstream>/pull/<N>` URL. This is the most
+ reliable signal — the reporter already noticed.
+2. **Code-pointer + vulnerability-class match in a recent PR.**
+ For each code pointer extracted in Step 2a (file path + function
+ name), search `<upstream>` for PRs that touch that surface and
+ whose title/body matches the candidate's vulnerability class
+ (e.g. *escape*, *sanitize*, *validate*, *auth*, *XSS*, *CVE*,
+ *security*). Run via the temp-file pattern from Step 2a (key
+ 3) — never put report-derived strings directly into the
+ `gh search prs` argument:
+
+ ```bash
+ # Write keywords to a temp file first; sanitise with `tr -cd`.
+ KW=$(tr -cd 'A-Za-z0-9._ -' < /tmp/pubfix-kw-<threadId>.txt)
+ gh search prs "$KW" --repo <upstream> \
+ --merged --merged-at ">=$(date -u -d '180 days ago' +%Y-%m-%d)" \
+ --json number,title,author,mergedAt,url --limit 10
+ gh search prs "$KW" --repo <upstream> --state open \
+ --json number,title,author,createdAt,url --limit 10
+ ```
+
+3. **GHSA cross-reference.** If the body contains a `GHSA-…` ID
+ that Step 2a did *not* match against an existing tracker,
+ search `<upstream>` for a PR that references that GHSA — some
+ projects file the GHSA-linked fix PR before the tracker exists.
+
+**Budget guardrail for Step 2c**: **≤ 3 `gh search prs` calls per
+candidate** (signals 1 + 2 + 3 above). If signal 1 finds a
+reporter-supplied PR URL, signals 2 and 3 are skipped (the
+reporter's own pointer is the strongest match available).
+
+**Match grading**:
+
+- **STRONG** — reporter linked the PR explicitly, OR the matched
+ PR's title/body explicitly names the same vulnerability class
+ on the same code surface (e.g. report says *"XSS in
+ `airflow/www/security/permissions.py:render_label`"* and the
+ PR title is *"Escape user-supplied label in `permissions.py`
+ to fix XSS"*).
+- **MEDIUM** — code surface matches and the vulnerability class
+ is plausible from the PR's diff scope, but the title is
+ generic (*"Fix permissions handling"*).
+- **WEAK** — same file but unrelated function, or same function
+ but a refactor PR with no security framing.
+
+Only STRONG matches route to `fix-already-public` in Step 3.
+MEDIUM matches surface as an *informational* note on the
+candidate's proposal entry (the triager may downgrade to
+`fix-already-public` manually during Step 5 confirmation if they
+read the PR and agree it covers the report). WEAK matches are
+ignored — too noisy to surface.
+
+**PR-was-filed-in-response check.** Before grading a match
+STRONG, confirm the PR was **not** filed *because of* this
+report. Heuristics:
+
+- PR author is on the security-team roster (cached at Step 0)
+ AND the PR creation date is *after* the candidate's email
+ arrival → likely filed in response; downgrade to a regular
+ `Report` candidate and let triage handle the credit
+ question.
+- PR description references the `<security-list>` thread or
+ contains language like *"reported via security@"* → same
+ treatment.
+- PR creation date is **before** the candidate's email arrival
+ → independent fix; STRONG match stands.
+
+**Surfacing in Step 5.** For each STRONG match, attach to the
+candidate's proposal entry:
+
+- a clickable PR link, author handle, merge state + date;
+- a one-line *"this PR appears to fix the reported behaviour"*
+ rationale;
+- a draft *thank-without-credit + verify-with-PR* reply (shape
+ in Step 5).
+
+For MEDIUM matches, attach the PR link with *"possible match,
+review before deciding"* framing — no draft reply unless the
+user upgrades to STRONG during confirmation.
+
+**Hard rule**: Step 2c is **read-only**. No comment on the PR,
+no email draft sent until Step 7 applies the user-confirmed
+disposition. The PR stays unaware of the report — same posture
+as `security-issue-import-from-pr`'s
+[*no outreach to the PR author about the
CVE*](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports)
+rule (the PR is public; revealing that a private security
+report came in about it leaks the private-channel content
+into a public surface).
+
+---
+
## Step 3 — Classify each candidate
For each remaining candidate, read the **root message only** (the one
@@ -861,6 +976,7 @@ Decide the candidate's class from the root message:
| **Media / research-disclosure request**: reporter wants to publish a blog or
talk about a finding we already know about | Body asks about disclosure timing,
mentions a talk / blog / CVE on another vendor | Surface class `media-request`;
do not auto-import. Propose the "When someone submits a media report" canned
reply. |
| **Obvious spam / scam / phishing / crypto-scheme** | Cryptocurrency
addresses, "bug bounty program" framing on a project that does not have one, no
actual Airflow-specific content | Surface class `spam`; propose no action (user
deletes in Gmail). |
| **Follow-up on existing thread that Step 2 missed** | Root message mentions
a CVE already allocated, or the body is *"re: <existing tracker>"* but with a
new threadId because the reporter replied from a different address | Surface
class `cross-thread-followup`; do not auto-import. Propose a comment on the
existing tracker instead. |
+| **Already fixed by a public PR** | Step 2c surfaced a STRONG match: a public
PR in `<upstream>` (open or merged, **not** filed in response to this report)
already appears to fix the reported behaviour. The reporter sent
`<security-list>` independently. | Surface class `fix-already-public`; **do
not** create a tracker. Propose a thank-without-credit Gmail draft per the
[no-credit-when-fix-is-already-public
policy](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-publ
[...]
**Classification is advisory, not dispositive.** When in doubt, class
the candidate as a `Report` and let the user make the call in Step 5 —
@@ -953,12 +1069,61 @@ Present all candidates as a single numbered proposal
grouped by class:
open questions that gate the import.
- **Candidates not to import** (class `automated-scanner`,
`consolidated-multi-issue`, `media-request`, `spam`,
- `cross-thread-followup`): show the class, the reporter, a one-line
- summary, and the proposed Gmail draft (from `canned-responses.md`)
- or the proposed follow-up action (e.g. *"comment on existing
- tracker [<tracker>#NNN](...)"*). These need explicit confirmation —
- no default-to-tracker. The draft **must** follow the
- canned-response discipline below.
+ `cross-thread-followup`, `fix-already-public`): show the class,
+ the reporter, a one-line summary, and the proposed Gmail draft
+ (from `canned-responses.md`, or — for `fix-already-public` —
+ from the *fix-already-public reply shape* below) or the proposed
+ follow-up action (e.g. *"comment on existing tracker
+ [<tracker>#NNN](...)"*). These need explicit confirmation — no
+ default-to-tracker. The draft **must** follow the canned-response
+ discipline below.
+
+### fix-already-public reply shape
+
+For each `fix-already-public` candidate, propose this draft (fill
+in the placeholders from the Step 2c match):
+
+> Thank you for taking the time to report this through
+> `<security-list>`. We noticed that
+> [`<upstream>#<NNN>`](https://github.com/<upstream>/pull/NNN)
+> ([`<author>`](https://github.com/<author>), <merged/opened> on
+> YYYY-MM-DD) already appears to address what you described.
+>
+> Per our policy, we do not add a finder when the fix to the
+> reported issue is already public at the time of report — but
+> we very much appreciate your effort in writing to us, and the
+> care you took to send it via the private channel.
+>
+> Could you check whether
+> [`<upstream>#<NNN>`](https://github.com/<upstream>/pull/NNN)
+> fixes the behaviour you observed? If it does, no further action
+> is needed on your side. **If after testing with this PR you
+> still see the issue, please reply on this thread with the
+> failing reproduction** and we will reopen the assessment as a
+> regular report.
+
+Substitute *"opened on"* when the PR is not yet merged. If Step
+2c surfaced multiple candidate PRs and the user has not yet
+narrowed to one, list each PR on its own line and ask the user
+to pick (or keep all if they each cover a different aspect of
+the report).
+
+This reply is the **disposition** for `fix-already-public`
+candidates — no tracker is created, no internal ticket opened.
+The audit trail lives on the `<security-list>` thread (the
+original report + this reply). If the reporter later confirms
+the PR fixes their report, the thread closes naturally. If they
+push back saying the PR does not fix it, their reply will
+re-surface in the next skill run and the candidate will be
+re-classified as a regular `Report`.
+
+**Reporter credit field.** The policy this inherits from
+[`security-issue-import-from-pr`](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports)
+applies symmetrically: no finder credit for a report that
+arrived after the fix went public. The user can override during
+Step 6 confirmation if there is a project-specific reason to
+credit (e.g. the reporter privately spotted the issue before the
+unrelated PR landed).
- **Dropped silently** (class `cve-tool-bookkeeping`): do not even
surface these to the user — they are consumed by
`security-issue-sync` Step 1e. The skill should just report the
@@ -1105,6 +1270,15 @@ to import; the user only types back to *deviate* from
that default):
this when the team has decided pre-triage that the report does
not warrant a tracker (Security-Model-fit miss, Dag-author-input
pattern, recently-closed duplicate, etc.).
+- `NN:reject-with-public-fix <PR-URL>` — reject candidate `NN`
+ upfront with the *fix-already-public reply shape* (see above),
+ using `<PR-URL>` as the cited public PR. Use this when Step 2c
+ missed an existing PR and the user knows about it manually, or
+ to upgrade a MEDIUM Step 2c match to a STRONG `fix-already-public`
+ disposition. **No tracker is created**; no finder credit is
+ recorded per the policy. Supply multiple `<PR-URL>` values
+ separated by commas if more than one PR collectively covers the
+ report.
- `NN:edit <freeform>` — fold a freeform note (extra context, a
different title, a smaller body excerpt) into the import; tracker
is still created with the edits applied.
@@ -1466,12 +1640,28 @@ For each confirmed `Report` / `ASF-security relay`:
append another entry, it can skip the Step 1 lookup.
For each confirmed non-import (automated-scanner / consolidated /
-media / cross-thread-followup):
-
-1. Draft the canned Gmail reply per the classification table in Step 3.
+media / cross-thread-followup / fix-already-public):
+
+1. Draft the Gmail reply.
+ - For `automated-scanner` / `consolidated-multi-issue` /
+ `media-request` / `cross-thread-followup`: use the canned
+ reply per the classification table in Step 3 (canned-response
+ discipline applies).
+ - For `fix-already-public`: use the *fix-already-public reply
+ shape* from Step 5, with placeholders filled from the Step 2c
+ match (or from the `NN:reject-with-public-fix <PR-URL>`
+ override). **No tracker is created**; no finder credit is
+ recorded. The Gmail thread carries the entire audit trail —
+ the original report on inbound and this reply on outbound.
2. If it is a cross-thread follow-up, optionally post a comment on the
existing `<tracker>` issue cross-linking the new Gmail
thread ID so the next sync picks it up.
+3. **Never comment on the public PR** for `fix-already-public`
+ dispositions. The PR stays unaware of the private report per
+ the same posture as
+ [`security-issue-import-from-pr`'s no-outreach
rule](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports);
+ revealing that a security report came in about the PR would
+ leak private-channel content into a public surface.
Apply sequentially (not in parallel): one `gh issue create` per
confirmed candidate, one draft per reply. If any step fails, stop and
diff --git a/.claude/skills/security-issue-triage/SKILL.md
b/.claude/skills/security-issue-triage/SKILL.md
index c62df06..9d622ed 100644
--- a/.claude/skills/security-issue-triage/SKILL.md
+++ b/.claude/skills/security-issue-triage/SKILL.md
@@ -4,12 +4,13 @@ mode: Triage
description: |
For each open `<tracker>` issue carrying the `needs triage`
label, read body + comments and classify the candidate
- disposition into one of five classes: VALID / DEFENSE-IN-DEPTH
- / INFO-ONLY / INVALID / PROBABLE-DUP. On user confirmation,
- posts a triage-proposal comment that invites the security team
- to react. Read-only on tracker state — no label flips, closes,
- or CVE allocations. Supports `--retriage` for re-litigating
- passed-triage decisions when substantive new activity lands.
+ disposition into one of six classes: VALID / DEFENSE-IN-DEPTH
+ / INFO-ONLY / INVALID / PROBABLE-DUP / FIX-ALREADY-PUBLIC. On
+ user confirmation, posts a triage-proposal comment that invites
+ the security team to react. Read-only on tracker state — no
+ label flips, closes, or CVE allocations. Supports `--retriage`
+ for re-litigating passed-triage decisions when substantive new
+ activity lands.
when_to_use: |
Invoke when a security team member says "triage open issues",
"start triage discussions on the new trackers", or "propose
@@ -98,7 +99,7 @@ comments. Once the team's decision lands and a sibling skill
applies the state change, *that* state change goes into the rollup
as a normal entry.
-**Golden rule 4 — five disposition classes, no more.** The
+**Golden rule 4 — six disposition classes, no more.** The
classification is a proposal, not a verdict; the team's reply may
escalate (`INFO-ONLY` → `VALID` after a clarifying technical
question lands) or de-escalate (`VALID` → `INVALID` if a
@@ -114,6 +115,7 @@ discussion rather than starting it.
| `INFO-ONLY` | Report is fact-correct but doesn't violate anything; matches a
known canned-response shape (educational reply, no tracker action needed) |
close + reporter-reply via the matching canned response |
| `INVALID` | Misframed, circular, by-design, or out-of-scope per the
canned-responses precedents |
[`/security-issue-invalidate`](../security-issue-invalidate/SKILL.md) |
| `PROBABLE-DUP` | Substantive overlap with an existing tracker or closed
advisory (same root cause; sibling attack vector with the same fix shape) |
[`/security-issue-deduplicate`](../security-issue-deduplicate/SKILL.md) |
+| `FIX-ALREADY-PUBLIC` | A public PR in `<upstream>` (open or merged) already
appears to fix the reported behaviour; the reporter sent `<security-list>`
independently of that PR. Per the [no-credit-when-fix-is-already-public
policy](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports),
reporter is thanked but not credited; reporter is asked to verify the PR
addresses what they reported, and to come back if it does not. |
[`/security-issue-invalidate`](. [...]
**Golden rule 5 — every `<tracker>` reference is a clickable
link**, per Golden rule 2 in
@@ -315,6 +317,27 @@ the inputs the classifier needs. Each tracker gets:
write code → the right next step is usually `VALID` →
`/security-cve-allocate`).
+ **Independent-public-fix detection.** Beyond PRs that already
+ reference the tracker, also search for *independent* public
+ PRs in `<upstream>` that plausibly fix the reported behaviour
+ without being aware of the report. Triggers:
+ - the reporter themselves links to a public PR in the body
+ (most reliable signal — they already noticed);
+ - a recent merged/open PR touches the same file + function the
+ report cites and its title/body matches the vulnerability
+ class (e.g. "fix XSS in …", "escape … input", "validate
+ …"), found via `gh search prs --repo <upstream> -- <path>
+ <vuln-keyword>` (≤ 2 calls per tracker, mirrors the Step 4
+ `@`-mention routing budget);
+ - the *PR with the fix* body field is empty but a sibling
+ tracker's PR — surfaced by Step 2's cross-reference search —
+ covers the same code surface.
+
+ A hit here routes to `FIX-ALREADY-PUBLIC` in Step 3 (not
+ `PROBABLE-DUP` — the dup class is for *tracker* overlap; this
+ class is for *PR-already-public* overlap when there may be no
+ sibling tracker at all).
+
4. **Reporter-thread followup** (only when the *Security
mailing list thread* body field resolves to a Gmail
`threadId`) — read the thread's last 3 messages with
@@ -582,11 +605,83 @@ The proposal links the candidate kept-tracker and suggests
`/security-issue-deduplicate <new> <existing>` as the next
slash command.
+#### `FIX-ALREADY-PUBLIC`
+
+Propose when **all** of:
+
+- The Step 2 *Independent-public-fix detection* surfaced a
+ public PR in `<upstream>` (open or merged) that plausibly
+ fixes the reported behaviour — same file + function as the
+ report's code pointer, title/body matches the vulnerability
+ class.
+- That PR was **not** filed in response to this tracker
+ (i.e. the PR predates the tracker, or the PR author is not on
+ the security-team roster and the PR description shows no
+ awareness of `<security-list>` or the tracker).
+- The report's technical premise is *plausibly correct* —
+ enough that the question *"does this PR fix what you
+ reported?"* is the load-bearing next step, not *"is this even
+ a real issue?"* (if the premise is wrong outright, propose
+ `INVALID` instead).
+
+**Reporter credit policy.** Per the
+[no-credit-when-fix-is-already-public
policy](../security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports)
+that this skill inherits from `security-issue-import-from-pr`:
+when the fix is already public at the time the report arrives,
+the reporter is **thanked but not credited as the finder**, for
+the same incentive-alignment reasoning (a public PR is not a
+responsible disclosure; awarding finder credit for reports of
+already-public fixes trains the next reporter to skip the
+private disclosure step). The team can override per-tracker
+during Step 5 confirmation if there is a project-specific
+reason to credit (e.g. the reporter privately spotted the
+issue before the unrelated PR landed).
+
+**Proposal body must include the draft reporter reply.** Per
+the read-only-on-tracker contract this skill maintains, the
+reply is **not** sent here — it is drafted for the team and
+will be sent later via
+[`/security-issue-invalidate`](../security-issue-invalidate/SKILL.md)
+once the team confirms. Draft template:
+
+> Thanks for the report. We noticed that
+> [`<upstream>#<NNN>`](https://github.com/<upstream>/pull/NNN)
+> ([`<author>`](https://github.com/<author>), merged YYYY-MM-DD)
+> already appears to address what you described. Per our policy,
+> we do not credit a finder when the fix to the reported issue
+> is already public at the time of report — but we very much
+> appreciate you taking the time to write to us.
+>
+> Could you check whether
+> [`<upstream>#<NNN>`](https://github.com/<upstream>/pull/NNN)
+> fixes the behaviour you observed? If it does, no further
+> action is needed on your side. If after testing with the PR
+> you still see the issue, please reply on this thread with
+> the failing reproduction and we will reopen the discussion.
+
+Fill in `<NNN>`, `<author>`, and the merge date from the Step 2
+detection. If the PR is open (not yet merged), substitute
+*"open since YYYY-MM-DD"* for *"merged YYYY-MM-DD"*. If multiple
+candidate PRs were surfaced, the draft lists each (the team
+trims during Step 5 confirmation).
+
+**Sibling skill hand-off.** After team consensus on the
+proposal:
+
+- If the reporter confirms the PR fixes their report →
+ [`/security-issue-invalidate`](../security-issue-invalidate/SKILL.md)
+ closes the tracker; the reporter-credit field stays blank.
+- If the reporter says the PR does **not** fix it →
+ re-triage via `--retriage` with the new evidence; the
+ classification will typically escalate to `VALID` /
+ `DEFENSE-IN-DEPTH` / `INVALID` based on the reporter's
+ follow-up.
+
### Confidence and edge cases
The classifier may emit `UNCERTAIN` internally — surface this
as *"low-confidence proposal, please challenge"* in the comment
-body rather than picking one of the five classes blindly. The
+body rather than picking one of the six classes blindly. The
team's reply on a flagged-uncertain tracker is what produces
the next iteration; **never** post a high-confidence-toned
proposal when the input state is ambiguous.
@@ -805,7 +900,7 @@ rate-limit; the user retries the remaining items with the
After the post loop, print a recap with:
- Disposition distribution (e.g. *"3 VALID, 1 DEFENSE-IN-DEPTH,
- 2 INVALID, 1 INFO-ONLY, 0 PROBABLE-DUP"*).
+ 2 INVALID, 1 INFO-ONLY, 0 PROBABLE-DUP, 1 FIX-ALREADY-PUBLIC"*).
- Per-tracker line: clickable issue link, class, comment URL.
- The set of sibling-skill next-step recommendations, grouped:
- `/security-cve-allocate NNN` for each VALID
@@ -813,6 +908,11 @@ After the post loop, print a recap with:
INFO-ONLY (the invalidate skill handles both with the right
canned response)
- `/security-issue-deduplicate NNN MMM` for each PROBABLE-DUP
+ - `/security-issue-invalidate NNN` for each FIX-ALREADY-PUBLIC,
+ *only after the reporter has confirmed the public PR fixes
+ their report* — until then, the tracker stays open awaiting
+ that verification; if the reporter says the PR does not fix
+ it, re-triage via `--retriage` instead
- A note that label flips and project-board moves stay with
`/security-issue-sync` once the team's decision lands — *not*
with this skill.
diff --git a/AGENTS.md b/AGENTS.md
index 874e00b..d79efb1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1293,10 +1293,12 @@ Currently available:
tracker carrying `needs triage`, reads body + comments, applies the
project's Security Model framing, and — on user confirmation — posts
a standalone top-level **triage-proposal comment** that classifies
- the candidate disposition into one of five classes (`VALID` →
+ the candidate disposition into one of six classes (`VALID` →
`security-cve-allocate`, `DEFENSE-IN-DEPTH` → public PR for
hardening, `INFO-ONLY` / `INVALID` → `security-issue-invalidate`,
- `PROBABLE-DUP` → `security-issue-deduplicate`) and `@`-mentions 2-3
+ `PROBABLE-DUP` → `security-issue-deduplicate`, `FIX-ALREADY-PUBLIC`
+ → reporter verifies the cited public PR, then
+ `security-issue-invalidate` if confirmed) and `@`-mentions 2-3
security-team members per scope for input. **Read-only on tracker
state** — never flips `needs triage` to a scope label, never closes,
never allocates a CVE; the valid/invalid decision belongs to team
diff --git a/docs/security/README.md b/docs/security/README.md
index 39201d9..3307f0d 100644
--- a/docs/security/README.md
+++ b/docs/security/README.md
@@ -35,7 +35,7 @@ and reuse the skills verbatim.
|
[`security-issue-import`](../../.claude/skills/security-issue-import/SKILL.md)
| Import new reports from `<security-list>` into `<tracker>`. |
|
[`security-issue-import-from-pr`](../../.claude/skills/security-issue-import-from-pr/SKILL.md)
| Open a tracker for a security-relevant fix opened as a public PR. |
|
[`security-issue-import-from-md`](../../.claude/skills/security-issue-import-from-md/SKILL.md)
| Bulk-import findings from a markdown report. |
-|
[`security-issue-triage`](../../.claude/skills/security-issue-triage/SKILL.md)
| Propose an initial-triage disposition (VALID / DEFENSE-IN-DEPTH / INFO-ONLY /
INVALID / PROBABLE-DUP) for each tracker still in `Needs triage`; opens a
discussion comment, never flips the label. |
+|
[`security-issue-triage`](../../.claude/skills/security-issue-triage/SKILL.md)
| Propose an initial-triage disposition (VALID / DEFENSE-IN-DEPTH / INFO-ONLY /
INVALID / PROBABLE-DUP / FIX-ALREADY-PUBLIC) for each tracker still in `Needs
triage`; opens a discussion comment, never flips the label. |
| [`security-issue-sync`](../../.claude/skills/security-issue-sync/SKILL.md) |
Reconcile a tracker against its mail thread, fix PR, release train, and
archives. |
|
[`security-cve-allocate`](../../.claude/skills/security-cve-allocate/SKILL.md)
| Allocate a CVE for a tracker (Vulnogram URL + paste-ready JSON). |
| [`security-issue-fix`](../../.claude/skills/security-issue-fix/SKILL.md) |
Implement the fix as a public PR in `<upstream>`. |
diff --git a/docs/security/process.md b/docs/security/process.md
index 926a7b8..7f482cb 100644
--- a/docs/security/process.md
+++ b/docs/security/process.md
@@ -151,7 +151,7 @@ skill automates the first half of this step: for each
tracker
in `Needs triage`, it reads the body + comments, applies the
project's Security Model framing, and — on user confirmation —
posts a top-level **triage-proposal comment** that classifies
-the candidate disposition into one of five classes and
+the candidate disposition into one of six classes and
`@`-mentions 2-3 security-team members for input. The
proposal-comment shape is:
@@ -162,7 +162,7 @@ proposal-comment shape is:
classes);
- a specific question for the `@`-mentioned reviewers.
-The five disposition classes route to different next-steps once
+The six disposition classes route to different next-steps once
team consensus lands:
| Class | Next step after consensus |
@@ -172,6 +172,7 @@ team consensus lands:
| `INFO-ONLY` |
[`security-issue-invalidate`](../../.claude/skills/security-issue-invalidate/SKILL.md)
with the matching canned-response template |
| `INVALID` |
[`security-issue-invalidate`](../../.claude/skills/security-issue-invalidate/SKILL.md)
|
| `PROBABLE-DUP` |
[`security-issue-deduplicate`](../../.claude/skills/security-issue-deduplicate/SKILL.md)
|
+| `FIX-ALREADY-PUBLIC` | After reporter confirms the cited public PR fixes
their report:
[`security-issue-invalidate`](../../.claude/skills/security-issue-invalidate/SKILL.md).
If the reporter says it does not fix it, re-triage with `--retriage`. No
finder credit is recorded per the [no-credit-when-fix-is-already-public
policy](../../.claude/skills/security-issue-import-from-pr/SKILL.md#reporter-credit-policy-for-public-pr-imports).
|
The triage skill is **read-only** on tracker state — it never
flips `needs triage` to a scope label, never closes, never