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 14d80e6 feat(security): forwarder-routing policy for trackers with no
direct reporter contact (#278)
14d80e6 is described below
commit 14d80e6e17326e4170b1cd8975649f7673effc81
Author: Jarek Potiuk <[email protected]>
AuthorDate: Mon May 25 16:17:10 2026 +0200
feat(security): forwarder-routing policy for trackers with no direct
reporter contact (#278)
When a tracker has no direct way to reach the original reporter --
ASF-security-relay reports, read-only GitHub Private Reporting, AI
scan markdown imports, anonymous tips -- the skills now route
reporter-facing communication through the forwarder (the security-
team member or relay service that delivered the report). In that
*via-forwarder mode*, only important lifecycle milestones are
relayed. Regular workflow chatter and credit-acceptance confirmation
messages are suppressed so the forwarder isn't pinged with
low-signal updates that would burn their goodwill.
- New `docs/security/forwarder-routing-policy.md`: single source of
truth. Defines four ways via-forwarder mode is detected (ASF-relay
sender, read-only GHSA, -from-md imports, explicit
`<!-- apache-steward: routing-mode via-forwarder -->` marker).
Milestones that DO relay: report-accepted-as-valid,
report-assessed-as-invalid, advisory-sent, additional-information
requests. Each milestone carries a short body template referencing
the external identifier (GHSA ID, HackerOne URL) rather than
re-stating the technical detail.
- *CVE allocated* is intentionally handled OUTSIDE the policy:
Vulnogram typically emits its own allocation notification, and the
team owes the reporter (or forwarder) a single short notification
here regardless of routing mode -- no recipient swap, no
suppression.
- Negative space is the *credit-acceptance confirmation* class:
follow-up "please confirm we will credit you as X" chase-ups and
the standalone bot/AI credit-clarification draft. The credit
*question* itself (initial one-line ask folded into a milestone
draft) is NOT suppressed -- the forwarder might know or might
relay it. The distinction: a question is cheap and one-shot;
a confirmation demands a reply the forwarder can't supply.
- `security-issue-import` Step 7 ASF-relay branch: re-framed as the
canonical via-forwarder receipt-of-confirmation. Folds the
credit question in as a single best-effort line; no standalone
credit-acceptance confirmation drafts.
- `security-issue-sync` reporter-draft section: applies the policy
to decide direct vs forwarder vs suppress, with a "skipped
reporter draft" recap line for non-milestone events.
- `security-issue-invalidate` Step 5d: re-framed as the *Report
assessed as invalid* milestone; explicit direct vs forwarder
recipient selection.
- `security-cve-allocate` Step 4 #5: re-framed as out-of-scope per
the policy. Same draft body in both modes; the credit *question*
is folded in (allowed by the question-vs-confirmation
distinction), the standalone re-confirmation is suppressed in
via-forwarder mode.
- `tools/vulnogram/bot-credits-policy.md`: defers to the new
policy. The standalone bot/AI credit-clarification draft (a
credit-acceptance confirmation by nature) is suppressed in
via-forwarder mode; the bot detection itself still runs.
- `docs/security/README.md` deep-doc index + `roles.md` *Shared
conventions -> Keeping the reporter informed*: link to the
policy.
Generated-by: Claude Code (Opus 4.7)
---
.claude/skills/security-cve-allocate/SKILL.md | 25 ++-
.claude/skills/security-issue-import/SKILL.md | 48 +++--
.claude/skills/security-issue-invalidate/SKILL.md | 28 ++-
.claude/skills/security-issue-sync/SKILL.md | 22 ++
docs/security/README.md | 7 +
docs/security/forwarder-routing-policy.md | 237 ++++++++++++++++++++++
docs/security/roles.md | 13 ++
tools/vulnogram/bot-credits-policy.md | 46 ++++-
8 files changed, 389 insertions(+), 37 deletions(-)
diff --git a/.claude/skills/security-cve-allocate/SKILL.md
b/.claude/skills/security-cve-allocate/SKILL.md
index 28c7c7f..f68e661 100644
--- a/.claude/skills/security-cve-allocate/SKILL.md
+++ b/.claude/skills/security-cve-allocate/SKILL.md
@@ -459,6 +459,22 @@ user to confirm. Numbered items:
been allocated, one sentence that the advisory will be sent
once the fix ships, the ASF CVE tool URL on its own line.
+ **This draft fires in both direct-reporter and via-forwarder
+ modes** and does **not** follow the
+ [forwarder-routing
policy](../../../docs/security/forwarder-routing-policy.md)
+ suppress-on-non-milestone rule. CVE allocation is treated as
+ out-of-scope for that policy (see its
+ [*Events handled outside this
policy*](../../../docs/security/forwarder-routing-policy.md#events-handled-outside-this-policy)
+ section): Vulnogram typically emits its own allocation email
+ when the CVE record is created, and even when it does not,
+ the team owes the reporter (or their forwarder) a single
+ short notification at this point regardless of routing mode.
+ The draft lands on whatever thread the tracker's
+ *Security mailing list thread* field resolves to — the
+ inbound reporter thread in direct-reporter mode, the relay
+ thread in via-forwarder mode — and the body is the same in
+ both cases.
+
**Before drafting, check for an existing pending draft** on the
inbound thread per the *Detecting drafts that already exist on a
thread* section of
@@ -468,7 +484,14 @@ user to confirm. Numbered items:
on the inbound `threadId` (catches thread-attached drafts that may
pile up and hide from the global Drafts folder, regardless of
backend). Re-ask the credit-preference question **only if it has
- not yet been asked** on the thread — never ping twice.
+ not yet been asked** on the thread — never ping twice. The
+ initial credit *question* (one-line ask folded into this draft)
+ fires in both routing modes per the forwarder-routing policy's
+ [question-vs-confirmation
distinction](../../../docs/security/forwarder-routing-policy.md#negative-space--do-not-relay);
+ what is suppressed in via-forwarder mode is *follow-up
+ credit-acceptance confirmation* messages, not the inclusion of
+ a one-line credit question in a milestone-class notification
+ like this one.
**Never send.** Create a Gmail draft via the project's configured
drafting backend per
diff --git a/.claude/skills/security-issue-import/SKILL.md
b/.claude/skills/security-issue-import/SKILL.md
index a7b10f8..a237c56 100644
--- a/.claude/skills/security-issue-import/SKILL.md
+++ b/.claude/skills/security-issue-import/SKILL.md
@@ -1067,7 +1067,7 @@ here.
| **Affected versions** | Extract `Airflow <version>` / `>= X, < Y` / `<Y`
phrases from the body. If the reporter gave only a single version they tested
on (e.g. `3.1.5`), record that verbatim; the triager can widen the range later.
Leave `_No response_` if no version is mentioned. |
| **Security mailing list thread** | **Keep the private thread handle, and —
if possible — also link the PonyMail archive entry.** The full URL-construction
recipe (search URL template, month-token format, user-pastes-back flow,
Gmail-threadId fallback) lives in
[`tools/gmail/ponymail-archive.md`](../../../tools/gmail/ponymail-archive.md#use-case--security-issue-import);
the adopting project's private-search URL template is declared in
[`<project-config>/project.md`](../../../<project-co [...]
| **Public advisory URL** | `_No response_`. Populated at Step 14 by
`security-issue-sync` once the advisory is archived. |
-| **Reporter credited as** | The reporter's full display name from the email
`From:` header (e.g. `Alice Example` from `"Alice Example"
<[email protected]>`). This is a **placeholder** — the receipt-of-confirmation
reply in Step 7 asks the reporter to confirm their preferred credit form.
**Apply the [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md) before populating** —
if the `From:`-header name or address matches the bot detection rule (`*[bot]`
suffix, known-bot l [...]
+| **Reporter credited as** | The reporter's full display name from the email
`From:` header (e.g. `Alice Example` from `"Alice Example"
<[email protected]>`). This is a **placeholder** — in direct-reporter mode, the
receipt-of-confirmation reply in Step 7 asks the reporter to confirm their
preferred credit form. **Apply the [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md) before populating** —
if the `From:`-header name or address matches the bot detection rule (`*[ [...]
| **PR with the fix** | `_No response_`. |
| **Remediation developer** | `_No response_`. Auto-populated by the
`security-issue-sync` skill from the linked PR's author the first time *PR with
the fix* is set; manual edits are preserved on subsequent syncs. The
auto-populate step applies the same [bot/AI credit
policy](../../../tools/vulnogram/bot-credits-policy.md). |
| **CWE** | `_No response_`. The security team scores CWE independently; a
reporter-supplied CWE is informational only (per the *"Reporter-supplied CVSS
scores are informational only"* rule in [`AGENTS.md`](../../../AGENTS.md)). Do
**not** copy a CWE from the reporter's body into this field. |
@@ -1595,31 +1595,43 @@ For each confirmed `Report` / `ASF-security relay`:
- **Class `ASF-security relay`** (the external reporter is
unreachable to us directly; only the ASF forwarder can relay
questions back to them through the original external channel —
- GHSA, HackerOne, direct mail) — `toRecipients` is the
- **personal `@apache.org` address of the ASF forwarder** (the
- `From:` of the inbound relay message), not `[email protected]`
- and not the unreachable external reporter. Body is **short**
- per the "Brevity: emails state facts, not context" rule in
+ GHSA, HackerOne, direct mail). This is the canonical
+ **via-forwarder mode** per
+
[`docs/security/forwarder-routing-policy.md`](../../../docs/security/forwarder-routing-policy.md);
+ the receipt-of-confirmation draft here is the first
+ forwarder-bound message in the lifecycle, and the rest of
+ the milestone drafts (CVE allocated, advisory sent,
+ invalidation, additional-information requests) follow the
+ same routing. `toRecipients` is the **personal
+ `@apache.org` address of the ASF forwarder** (the `From:` of
+ the inbound relay message), not `[email protected]` and
+ not the unreachable external reporter. Body is **short** per
+ the "Brevity: emails state facts, not context" rule in
[`AGENTS.md`](../../../AGENTS.md):
- one sentence acknowledging receipt, linking to the external
reference (GHSA ID, HackerOne report URL);
- - one sentence asking the forwarder to relay the
- credit-preference question below through the original
- channel;
- - the credit-preference question itself (two or three lines,
- adapted from the canned response — *"We will credit you in
- the CVE record as <reporter-credited-as placeholder>. If
- you would prefer a different credit line — full name,
- handle, affiliation, or "anonymous" — please let us know
- before the advisory goes out."*).
+ - one sentence asking the forwarder, **best-effort**, to
+ pass any preferred credit form back if the reporter has
+ one — folded in as a single line per the
+ forwarder-routing policy's
+ [question-vs-confirmation
distinction](../../../docs/security/forwarder-routing-policy.md#negative-space--do-not-relay)
+ (initial credit *question* is allowed in milestone-class
+ drafts; what is suppressed is *follow-up
+ credit-acceptance confirmation* messages on subsequent
+ sync passes).
Do **not** restate the vulnerability, the severity, or the
Airflow handling process — the ASF security team already
- knows all of that. See the
+ knows all of that. **Do not** include any of the negative-
+ space items from the forwarder-routing policy (regular
+ workflow status, standalone credit-acceptance confirmation
+ drafts, reviewer-comment relays). See
+
[`docs/security/forwarder-routing-policy.md`](../../../docs/security/forwarder-routing-policy.md)
+ for the full milestone list + negative space and the
"ASF-security-relay reports: a special case for drafting"
- section in [`AGENTS.md`](../../../AGENTS.md) for the full
- rationale.
+ section in [`AGENTS.md`](../../../AGENTS.md) for the
+ drafting-mechanics rationale.
**Never send.** Always create a draft; the triager reviews in
Gmail before sending.
diff --git a/.claude/skills/security-issue-invalidate/SKILL.md
b/.claude/skills/security-issue-invalidate/SKILL.md
index 420f071..8869901 100644
--- a/.claude/skills/security-issue-invalidate/SKILL.md
+++ b/.claude/skills/security-issue-invalidate/SKILL.md
@@ -481,19 +481,31 @@ informational, not a blocker).
Skip this entire substep when the import path detected in Step 2
is *PR-imported*.
-For `security@`-imported trackers:
+For `security@`-imported trackers, the invalidation reply is one
+of the five [forwarder-routing-policy
milestones](../../../docs/security/forwarder-routing-policy.md#milestones--do-relay)
+(*Report assessed as invalid*) — so the draft fires in both
+direct-reporter and via-forwarder modes; the policy only changes
+the **recipient** and the **body shape**.
1. **Recipients:**
- - `toRecipients`: `tracker.reporterEmail` (the `From:` of
- the inbound root message). If the import was via the
- ASF-security relay path (the `From:` is a `@apache.org`
- forwarder, not the external reporter), reply to the
- forwarder per the *ASF-security relay* convention in
- [`security-issue-import` Step 7](../security-issue-import/SKILL.md).
+ - **Direct-reporter mode**: `toRecipients` is
+ `tracker.reporterEmail` (the `From:` of the inbound root
+ message). The reply lands on the inbound thread via thread
+ attachment.
+ - **Via-forwarder mode** (ASF-security relay or any other case
+ in the [policy's detection
list](../../../docs/security/forwarder-routing-policy.md#when-does-via-forwarder-mode-apply)):
+ `toRecipients` is the **forwarder contact** (the
+ `@apache.org` forwarder address from the inbound `From:` for
+ ASF-relay, or the named contact from the explicit
+ no-direct-contact marker comment). The body follows the
+ *Report assessed as invalid* milestone-body shape in the
+ policy doc — short, references the external identifier (GHSA
+ ID, HackerOne URL) rather than restating the technical
+ detail.
- `ccRecipients`: always includes `<security-list>`
(`<security-list>` for the adopting project) —
value comes from
-
[`<project-config>/project.md`](../../../<project-config>/project.md#gmail-and-ponymail).
+
[`<project-config>/project.md`](../../../<project-config>/project.md#mail-sources).
2. **Subject:** `Re: <root subject>`. Never invent a fresh
subject — the reply lands on the inbound thread via
thread attachment (`replyToMessageId` for `claude_ai_mcp`,
diff --git a/.claude/skills/security-issue-sync/SKILL.md
b/.claude/skills/security-issue-sync/SKILL.md
index 3efed88..e560155 100644
--- a/.claude/skills/security-issue-sync/SKILL.md
+++ b/.claude/skills/security-issue-sync/SKILL.md
@@ -1876,6 +1876,28 @@ will change and *why*. Group them by category:
artifact link. See the "Brevity: emails state facts, not context"
section of [`AGENTS.md`](../../../AGENTS.md).
+ **Apply the [forwarder-routing
policy](../../../docs/security/forwarder-routing-policy.md)
+ to decide whether to propose the draft at all.** Run the detection
+ rules in the policy doc to determine the tracker's routing mode:
+
+ * **Direct-reporter mode** — proceed as written above; the draft
+ targets the reporter on the inbound thread.
+ * **Via-forwarder mode + event is on the [milestone
list](../../../docs/security/forwarder-routing-policy.md#milestones--do-relay)**
+ (report accepted as valid, CVE allocated, advisory sent,
+ invalidation, or a specific *"we need additional information"*
+ question) — propose the draft to the **forwarder contact**, not
+ the reporter, using the short milestone-body shape from the
+ policy doc. Reference the external identifier (GHSA ID,
+ HackerOne URL, internal ticket number) rather than repeating
+ the technical detail of the report.
+ * **Via-forwarder mode + event is NOT on the milestone list**
+ (regular workflow status, credit-form questions, reviewer-
+ comment relays) — **suppress the draft entirely**. Record in
+ the proposal recap *"skipped reporter draft: `<event>` not on
+ the via-forwarder milestone list"* so the user can see why
+ no message was proposed. The forwarder is not pinged with
+ low-signal updates.
+
**Never send.** Always create a draft. Prefer attaching it to the
inbound mail thread (the default `claude_ai_mcp` backend resolves
the latest message ID from the inbound `threadId` and passes it as
diff --git a/docs/security/README.md b/docs/security/README.md
index d514e72..537be7a 100644
--- a/docs/security/README.md
+++ b/docs/security/README.md
@@ -74,6 +74,13 @@ and reuse the skills verbatim.
threat model for the security skill family: trust boundaries,
adversary personas, STRIDE matrix per skill, mitigation cross-
reference, residual risk, and the re-audit cadence.
+- [**`forwarder-routing-policy.md`**](forwarder-routing-policy.md) —
+ when a tracker has no direct reporter contact (ASF-relay,
+ read-only GHSA, anonymous tip), the skills route reporter-facing
+ communication through the forwarder. The policy defines when
+ that mode applies, the milestone list (events that **do** get
+ relayed), and the negative list (events that don't — including
+ credit-confirmation questions and regular workflow status).
## Adopter contract
diff --git a/docs/security/forwarder-routing-policy.md
b/docs/security/forwarder-routing-policy.md
new file mode 100644
index 0000000..88d18c8
--- /dev/null
+++ b/docs/security/forwarder-routing-policy.md
@@ -0,0 +1,237 @@
+<!-- START doctoc generated TOC please keep comment here to allow auto update
-->
+<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
+**Table of Contents** *generated with
[DocToc](https://github.com/thlorenz/doctoc)*
+
+- [Forwarder-routing policy](#forwarder-routing-policy)
+ - [When does via-forwarder mode apply?](#when-does-via-forwarder-mode-apply)
+ - [Milestones — DO relay](#milestones--do-relay)
+ - [Events handled outside this policy](#events-handled-outside-this-policy)
+ - [Negative space — DO NOT relay](#negative-space--do-not-relay)
+ - [Implementation in the skills](#implementation-in-the-skills)
+ - [Worked examples](#worked-examples)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+ https://www.apache.org/licenses/LICENSE-2.0 -->
+
+# Forwarder-routing policy
+
+When a security tracker has **no direct way to reach the original
+reporter** — there is no individual reporter email, GitHub Private
+Reporting access is read-only, or the report arrived through a
+forwarding service — the skills route reporter-facing communication
+through whoever delivered the report to us instead (the *forwarder*
+— typically the Apache Security team relaying via
+`[email protected]`, or an internal security-team member who
+opened the tracker on someone else's behalf).
+
+In that **"via-forwarder" mode**, only **important milestones** are
+relayed. Regular workflow chatter and credit-confirmation
+questions are **not** sent to the forwarder — they would burn the
+forwarder's goodwill with low-signal updates the forwarder has no
+useful reply to.
+
+This file is the single source of truth for *when* the
+via-forwarder mode applies and *what* gets relayed.
+
+## When does via-forwarder mode apply?
+
+The mode applies to a tracker when **any** of the following is true:
+
+1. **ASF-security relay.** The inbound report came from
+ `[email protected]` with the ASF forwarding preamble; the
+ original reporter is not addressable directly on the relayed
+ thread. The personal `@apache.org` address of the forwarding
+ security-team member is the **forwarder contact**. (See
+ [`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md) for
+ the detection mechanics.)
+2. **GitHub Private Reporting we cannot reply on.** A GHSA-style
+ private report we have read access to but can't post comments
+ on as a security team. Whoever made us aware of the GHSA
+ (typically the same Apache Security team member, or an internal
+ escalation thread) is the forwarder.
+3. **`security-issue-import-from-md`-imported tracker.** The
+ tracker came from a markdown file (AI scan / third-party scan
+ output) with no inbound reporter at all. There is no reporter to
+ relay to; treat the security-team member who ran the import as
+ the forwarder for any *"additional information"* questions.
+4. **Explicit no-direct-contact marker.** A security-team member
+ sets the marker comment
+
+ ```html
+ <!-- apache-steward: routing-mode via-forwarder -->
+ ```
+
+ on the tracker (one line in the body, or as the first line of a
+ pinned comment) and names the forwarder contact in the comment
+ body. This is the fall-through escape hatch for cases the
+ automatic detection above misses — an internal escalation, a
+ chat-only report, a printed letter, anything else where the
+ normal reporter address simply does not exist.
+
+The trackers opened by
+[`security-issue-import-from-pr`](../../.claude/skills/security-issue-import-from-pr/SKILL.md)
+are a **separate case** with its own *no outreach to the PR
+author* rule (see that skill's `Reporter credit policy for
+public-PR imports` section). The forwarder-routing policy does
+**not** apply there — the PR author is not someone we are
+deliberately relaying through, just someone whose public PR we
+imported. No reporter-facing drafts of any kind are proposed.
+
+## Milestones — DO relay
+
+Only these events warrant a draft to the forwarder. They map 1:1
+to the lifecycle decisions a reporter (or the team that relayed
+on their behalf) would actually want to hear about:
+
+| Milestone | Where it fires | Draft body summary |
+|---|---|---|
+| **Report accepted as valid** | After Step 5 lands a `valid` consensus +
scope label applied | *"We received the report you forwarded; the team has
confirmed it as valid. A CVE will be allocated next; we will write again when
the advisory is sent. If you can pass this update back to the original
reporter, please do; if you can also ask them to reply with their preferred
credit form, that would help — otherwise we'll proceed with the credit line in
the original report."* |
+| **Report assessed as invalid** |
[`security-issue-invalidate`](../../.claude/skills/security-issue-invalidate/SKILL.md)
— closing reply | *"The team has assessed the report you forwarded and
concluded it is not a security vulnerability. Reasoning: \<one-paragraph
summary\>. If you (or the original reporter) want to challenge the assessment,
please reply with the additional context; otherwise this is our final
disposition."* |
+| **Advisory sent** |
[`security-issue-sync`](../../.claude/skills/security-issue-sync/SKILL.md) Step
14 close-out — after the advisory archive URL is captured | *"The advisory for
`CVE-YYYY-NNNNN` has been sent and is archived publicly at `<URL>`. This
completes the lifecycle for the report you forwarded; thank you for the
relay."* |
+| **Additional information requested** | Any skill that needs a specific
clarification from the reporter (re-reproduction steps, attack-vector
clarification, affected-version range) | *"We need additional information to
assess the report you forwarded: `<specific question(s)>`. If you can relay
this to the original reporter and pass back a reply, that would help us land a
decision."* |
+
+The drafts go to the **forwarder contact**, not to the relay list
+address — same rule as the existing ASF-relay flow in
+[`tools/gmail/asf-relay.md`](../../tools/gmail/asf-relay.md). The
+body is short, references the external identifier (GHSA ID,
+HackerOne URL, internal ticket number) when one exists, and never
+re-states the technical detail of the report.
+
+## Events handled outside this policy
+
+Some lifecycle events generate reporter-facing notifications
+*outside* this policy's milestone / negative-space rules. They
+fire **the same way in both direct-reporter and via-forwarder
+modes** — no recipient swap, no body-shape swap, no
+suppression.
+
+* **CVE allocated**
+
([`security-cve-allocate`](../../.claude/skills/security-cve-allocate/SKILL.md)
+ Step 4 #5). Vulnogram typically emits its own allocation
+ notification when the CVE record is created, and even when
+ it doesn't, the team owes the reporter (or their forwarder)
+ a single short notification at this point regardless of
+ routing mode. The notification lands on whatever thread the
+ tracker's *Security mailing list thread* field resolves to;
+ in via-forwarder mode that is the relay thread, so the same
+ draft reaches the forwarder without any policy-specific
+ re-routing. The credit-preference question is still
+ suppressed in via-forwarder mode (per the
+ [Negative space](#negative-space--do-not-relay) section
+ below) but the rest of the CVE-allocated notification still
+ fires.
+
+Future events that follow the same shape (independent of
+routing mode, not subject to milestone-only suppression) belong
+here. Add a bullet and a one-paragraph explanation; do **not**
+hide the event by silently omitting it from the milestone list.
+
+## Negative space — DO NOT relay
+
+These events would generate a draft in *direct-reporter* mode but
+are **suppressed** in via-forwarder mode:
+
+* **Regular workflow status** — `pr created` / `pr merged` /
+ `fix released` label transitions. The forwarder has no actionable
+ reason to learn about these intermediate states; the next
+ forwarder-bound message is at advisory-sent (the *Advisory sent*
+ milestone above).
+* **Credit-acceptance confirmations** — i.e. messages asking the
+ reporter to *confirm receipt and acceptance of the credit line
+ the team plans to use*. The standalone
+ [bot/AI credit-clarification
draft](../../tools/vulnogram/bot-credits-policy.md)
+ belongs to this class (it asks *"is this AI/bot handle the
+ intended credit, or should we credit someone else?"* — a
+ confirmation prompt on a proposed credit). So do the
+ follow-up chase-ups *"please confirm we will credit you as
+ X"* that direct-reporter sync passes would otherwise generate
+ when the reporter has gone silent. The forwarder cannot
+ meaningfully accept a credit on behalf of the original
+ reporter, so the prompt bounces back and burns goodwill.
+
+ **The credit *question* itself is not suppressed.** Folding
+ a single short *"if the reporter has a preferred credit form,
+ please pass it back"* line into a milestone draft (the Step 7
+ receipt-of-confirmation, the *Report accepted as valid*
+ milestone, or the *CVE allocated* notification per the
+ [Events handled outside this policy](#events-handled-outside-this-policy)
+ section) is fine — the forwarder might know, or might be
+ able to relay the question through the original channel. The
+ distinction is:
+
+ * *Question* (allowed): a one-line ask included in a
+ milestone message the team is already sending. Cheap; the
+ forwarder either knows or can relay or drops it.
+ * *Confirmation* (suppressed): a message whose entire purpose
+ is to get the reporter to *accept* a credit line the team
+ has chosen. Demands a reply the forwarder can't supply, so
+ it becomes a chase-up loop.
+
+ The bot-credit detection still runs in via-forwarder mode and
+ still filters the auto-extracted credit before it lands in
+ the body field; it just does not generate its own
+ confirmation message.
+* **Reviewer-comment relays.** CVE reviewer feedback that lands on
+ `<security-list>` is handled by the security team internally; the
+ forwarder is not on that loop and does not need to be.
+* **Sync-rollup notifications.** The internal rollup comments
+ `security-issue-sync` posts on the tracker stay on the tracker —
+ they are for the security team, not for the forwarder.
+
+## Implementation in the skills
+
+Each skill that composes a reporter-facing draft checks the
+tracker's routing mode (using the detection rules in this doc) and
+applies one of three behaviours:
+
+1. **Direct-reporter mode** (the common case) — proceed exactly as
+ the skill's existing draft logic prescribes.
+2. **Via-forwarder mode + the event is on the milestone list** —
+ compose the draft to the forwarder contact instead of the
+ reporter, using the short milestone-body shape above. Reference
+ the external identifier rather than the technical detail.
+3. **Via-forwarder mode + the event is NOT on the milestone list**
+ — suppress the draft entirely. Record in the proposal recap
+ *"skipped draft: `<event>` not on the via-forwarder milestone
+ list"* so the user can see why no message was proposed.
+
+The detection runs once per skill invocation; subsequent dispatch
+through the skill is consistent for that run.
+
+## Worked examples
+
+**ASF-relayed GHSA report, advisory sent.** A report arrives via
+`[email protected]` carrying a GHSA reference; the import skill
+classifies it as `ASF-security relay`, drafts the Step 7 receipt to
+the forwarder (Arnout / the Apache Security team member who
+relayed it). Weeks later the fix ships and the advisory is
+archived on the users-list; `security-issue-sync` Step 14
+captures the URL and proposes an *Advisory sent* milestone draft
+to the same forwarder contact: *"The advisory for
+`CVE-2026-12345` has been sent and is archived publicly at
+`<URL>`. This completes the lifecycle for the report you
+forwarded; thank you for the relay."* No technical detail is
+restated. (The intermediate *CVE allocated* notification landed
+on the same thread per the
+[Events handled outside this policy](#events-handled-outside-this-policy)
+rule, so the forwarder already saw it.)
+
+**Bot-credit candidate in via-forwarder mode.** The relayed report
+names the discoverer as `Automated Scanner v3`. The bot-credit
+policy detects the match and would normally propose a
+clarification draft to the reporter. In via-forwarder mode the
+clarification draft is **suppressed**; the credit field stays at
+`_No response_` until the next milestone draft, where a single
+line *"if the original reporter has a preferred credit form,
+please pass it back"* is folded in.
+
+**Internal-escalation tracker.** An ASF PMC member forwards a
+private internal report to the security team verbally; the
+security team opens the tracker by hand and writes the
+`<!-- apache-steward: routing-mode via-forwarder -->` marker
+comment naming the PMC member as the forwarder contact. From that
+point on, every sync skill that would draft to a reporter routes
+to the named PMC member instead, and milestone-only suppression
+applies as if the tracker had come in via ASF-relay.
diff --git a/docs/security/roles.md b/docs/security/roles.md
index 2ac6100..44158d6 100644
--- a/docs/security/roles.md
+++ b/docs/security/roles.md
@@ -100,6 +100,19 @@ Reusable wording for the common cases lives in
[`<project-config>/canned-responses.md`](<project-config>/canned-responses.md)
— consult it before drafting a
reply from scratch.
+**When there's no direct reporter contact** (ASF-relay reports,
+read-only GHSA, anonymous tips), the team communicates with the
+*forwarder* instead — the security-team member or relay service
+that delivered the report. In that **via-forwarder mode**, only
+the five lifecycle milestones (report accepted as valid, report
+invalidated, CVE allocated, advisory sent, additional information
+requested) are relayed. Regular workflow status (label flips,
+PR-opened, PR-merged) and credit-confirmation questions are
+**not** sent to the forwarder — they would burn the forwarder's
+goodwill with low-signal updates. See
+[`forwarder-routing-policy.md`](forwarder-routing-policy.md) for
+detection rules, the full milestone list, and the negative space.
+
### Recording status transitions on the tracker
**Every status transition must also be recorded as a comment on the GitHub
diff --git a/tools/vulnogram/bot-credits-policy.md
b/tools/vulnogram/bot-credits-policy.md
index f13c421..5cb9d90 100644
--- a/tools/vulnogram/bot-credits-policy.md
+++ b/tools/vulnogram/bot-credits-policy.md
@@ -120,13 +120,16 @@ extraction time:
override to other trackers — overrides are per-tracker.
4. **Draft a clarification reply to the reporter when an email
- thread exists.** When the candidate would have been credited as
- the *finder* (i.e. the reporter themselves is the bot-looking
- name) **and** the tracker has an inbound `<security-list>` mail
- thread to reply on, propose a **Gmail draft** (not a sent
- message) on the same thread asking whether the bot/AI handle is
- the intended credit or whether there's a human behind it who
- should be credited instead. The draft should be:
+ thread exists *and* the tracker is in direct-reporter mode.**
+ When the candidate would have been credited as the *finder*
+ (i.e. the reporter themselves is the bot-looking name), the
+ tracker has an inbound `<security-list>` mail thread to reply
+ on, **and** the tracker's routing mode (per
+
[`docs/security/forwarder-routing-policy.md`](../../docs/security/forwarder-routing-policy.md))
+ is *direct-reporter*, propose a **Gmail draft** (not a sent
+ message) on the same thread asking whether the bot/AI handle
+ is the intended credit or whether there's a human behind it
+ who should be credited instead. The draft should be:
* **Polite and short** — one or two short paragraphs; no
accusations, no jargon.
@@ -139,9 +142,32 @@ extraction time:
* **Sent only after explicit user confirmation** per the
[framework's never-send-without-asking rule](../../AGENTS.md).
- When the tracker has no inbound mail thread (e.g. a
- `security-issue-import-from-pr` tracker), skip the draft step —
- there is no reporter to ask.
+ **In via-forwarder mode the standalone clarification draft
+ is suppressed.** It is a *credit-acceptance confirmation*
+ message (asking the reporter to confirm the AI/bot handle is
+ the intended credit, or to accept a different one) — and
+ credit-acceptance confirmations are on the
+ [forwarder-routing-policy negative
list](../../docs/security/forwarder-routing-policy.md#negative-space--do-not-relay).
+ The forwarder cannot meaningfully accept a credit on behalf
+ of the original reporter, so the message becomes a chase-up
+ loop. The bot-credit detection still runs and still keeps
+ the bot/AI handle out of the credit field; what is suppressed
+ is the dedicated message to confirm the alternative.
+
+ The credit *question* itself (initial ask, *"if the reporter
+ has a preferred credit form, please pass it back"*) is **not**
+ suppressed — it is folded as a single line into whatever
+ milestone draft the via-forwarder lifecycle next produces (the
+ Step 7 receipt-of-confirmation, the *Report accepted as
+ valid* milestone, the *CVE allocated* notification). The
+ credit field stays at `_No response_` (or whatever the
+ original report's `Credit:` line yielded after bot filtering)
+ until a meaningful answer comes back.
+
+ When the tracker has no inbound mail thread at all (e.g. a
+ `security-issue-import-from-pr` tracker — the
+ forwarder-routing policy explicitly does **not** apply
+ there), skip the draft step — there is no reporter to ask.
A reusable template for the draft body lives in
[`<project-config>/canned-responses.md`](../../<project-config>/canned-responses.md);