You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@airflow.apache.org by po...@apache.org on 2026/05/04 20:44:27 UTC

(airflow-steward) 01/01: refactor(privacy-llm): refine PII contract — reporter NOT redacted; only third parties

This is an automated email from the ASF dual-hosted git repository.

potiuk pushed a commit to branch feat/privacy-llm-foundation
in repository https://gitbox.apache.org/repos/asf/airflow-steward.git

commit 8d10b88c552432d3a12bfe2e30078a8c8121e546
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Mon May 4 22:44:14 2026 +0200

    refactor(privacy-llm): refine PII contract — reporter NOT redacted; only third parties
    
    The reporter sent the security@ mail and is operationally known to
    the security team (CVE credit, reply threads, sync comments). Their
    own identity flows through the agent's context as-is. **What gets
    redacted** is PII the reporter discloses about *other people* —
    third-party researchers, victims, named individuals other than the
    reporter. **Exception:** if the named third party is already a
    collaborator on the <tracker> repo (resolved via `gh api
    repos/<tracker>/collaborators`, the same source-of-truth as the
    prompt-injection rule), they are NOT redacted — their identity is
    already public/known via collaborator status, no privacy gain.
    
    Renamed the type code R/reporter to N/name throughout (code +
    tests + docs) to remove cognitive dissonance between the type
    label and the new contract — the prefix character `R` for
    "reporter" while the contract says reporters aren't redacted is a
    foot-gun. PR-1 isn't merged yet so no migration needed.
    
    Files updated:
    - `tools/privacy-llm/pii.md` — flipped the "What counts as PII"
      table; new "Who counts as a collaborator (not redacted)"
      section with the gh api lookup; reframed prose throughout.
    - `tools/privacy-llm/tool.md` — mechanism table flipped; "PII
      discovery + reporter-vs-third-party + collaborator-vs-not
      filtering is the skill's job" added to the contract.
    - `tools/privacy-llm/models.md` — note alongside the gate flow
      reminding readers the redactor handles security@ third-party
      PII separately.
    - `tools/privacy-llm/redactor/src/redactor/{mapping,redact}.py` —
      TYPE_CODES `"R": "reporter"` → `"N": "name"`; parse_field
      validation set updated.
    - `tools/privacy-llm/redactor/tests/*` — every R/reporter
      literal renamed; test assertions updated; example text
      reframed where it implied the named value was the reporter.
    - `tools/privacy-llm/redactor/README.md` — invocation examples
      use `name:"Other Researcher"` (clearly third-party) instead
      of `reporter:"Jane Smith"` (ambiguous); type table reworded.
    - `docs/setup/privacy-llm.md` — "Two mechanisms recap" reframed;
      sanity-check uses a third-party example.
    - `projects/_template/privacy-llm.md` — comment about
      `<security-list>` clarified.
    - `AGENTS.md` — the "Reporter PII never enters any LLM" rule
      flipped to the third-party / collaborator-aware version.
    
    Tests: 48/48 pass. Ruff + mypy clean. `prek run --all-files`
    clean (doctoc auto-regen'd pii.md's TOC for the new collaborator
    subsection).
    
    Generated-by: Claude Code (Claude Opus 4.7)
---
 AGENTS.md                                          |  27 ++++--
 docs/setup/privacy-llm.md                          |  29 ++++--
 projects/_template/privacy-llm.md                  |   5 +-
 tools/privacy-llm/models.md                        |   5 +-
 tools/privacy-llm/pii.md                           | 101 ++++++++++++++++-----
 tools/privacy-llm/redactor/README.md               |  26 +++---
 tools/privacy-llm/redactor/src/redactor/mapping.py |   4 +-
 tools/privacy-llm/redactor/src/redactor/redact.py  |   2 +-
 tools/privacy-llm/redactor/tests/test_list_cmd.py  |   6 +-
 tools/privacy-llm/redactor/tests/test_mapping.py   |  44 ++++-----
 tools/privacy-llm/redactor/tests/test_redact.py    |  30 +++---
 tools/privacy-llm/redactor/tests/test_reveal.py    |  16 ++--
 tools/privacy-llm/tool.md                          |  17 ++--
 13 files changed, 193 insertions(+), 119 deletions(-)

diff --git a/AGENTS.md b/AGENTS.md
index a8b8659..a9696e8 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -662,18 +662,27 @@ Setup recipes for the supported variants are in
 
 Three rules every skill follows:
 
-**Reporter PII never enters any LLM in the clear.** The
-reporter's name, email, phone, IP, personal handle, and other PII
-are replaced with hash-prefixed identifiers (`R-a3f9d2`,
-`E-b8c247`, …) immediately after fetch and before any LLM-bound
-step — including Claude's own context. The mapping from
-identifier to real value lives at
+**Third-party PII in `<security-list>` reports gets redacted —
+the reporter's own identity does not.** The reporter sent the
+mail to `<security-list>` and is operationally known to the
+security team (the team replies to them, credits them in the
+CVE, and references them across the tracker discussion). Their
+name, email, phone, etc. flow through the agent's context as-is.
+**What gets redacted** is PII the reporter discloses about
+*other people* — third-party researchers they collaborated with,
+victims they observed the bug affecting, named individuals
+called out in the body — replaced with hash-prefixed identifiers
+(`N-a3f9d2`, `E-b8c247`, …). **Exception:** if the named
+individual is already a collaborator on the `<tracker>` repo
+(resolved via `gh api repos/<tracker>/collaborators`), their
+identity is already public/known by their collaborator status
+and is **not** redacted — there is no privacy gain from masking
+them. The mapping from identifier to real value lives at
 `~/.config/apache-steward/pii-mapping.json` (per the home-dir
 credentials rule in [Local setup](#local-setup)) and is never
 sent to any LLM. Reveal-to-real-name happens only at the
-outbound boundary, when a draft to the reporter is being assembled.
-The contract is in
-[`tools/privacy-llm/pii.md`](tools/privacy-llm/pii.md).
+outbound boundary, when a draft is being assembled. The contract
+is in [`tools/privacy-llm/pii.md`](tools/privacy-llm/pii.md).
 
 **`<private-list>` content never reaches a non-approved LLM.**
 PMC-private foundation list content (the project's
diff --git a/docs/setup/privacy-llm.md b/docs/setup/privacy-llm.md
index 9f7eb7c..09d5a80 100644
--- a/docs/setup/privacy-llm.md
+++ b/docs/setup/privacy-llm.md
@@ -40,11 +40,17 @@ The framework treats two distinct privacy concerns separately:
 
 1. **PII redaction** — applies to `<security-list>` content (the
    reporter mail). The body is OK to flow through any approved
-   LLM, but reporter PII (name, email, phone, IP, personal
-   handle) is replaced with hash-prefixed identifiers
-   (`R-a3f9d2`, …) before any LLM step. The mapping is local to
-   the user's machine. This applies under **every** variant
-   below — even Variant 1 (Claude-only).
+   LLM. The reporter's own identity (name, email, etc.) flows
+   as-is — they sent the mail and are operationally known to the
+   security team. **What gets redacted** is PII the reporter
+   discloses about *other people* (third-party researchers,
+   victims, named individuals other than the reporter), replaced
+   with hash-prefixed identifiers (`N-a3f9d2`, …) before any LLM
+   step — *unless* the named individual is already a collaborator
+   on the `<tracker>` repo (their identity is already public/known
+   via collaborator status, no privacy gain from redacting). The
+   mapping is local to the user's machine. This applies under
+   **every** variant below — even Variant 1 (Claude-only).
 2. **Approved-LLM gate** — applies to `<private-list>` content
    (PMC private mail) and any other private foundation lists.
    The skill refuses to fetch unless every LLM in the active
@@ -334,17 +340,20 @@ Once `<project-config>/privacy-llm.md` is in place:
    ```
 
    Returns exit code 0 if the active stack is fully approved.
-3. Sanity-check the redactor end-to-end:
+3. Sanity-check the redactor end-to-end. The third party in this
+   example is `Other Researcher` (someone the reporter mentions
+   in their report; the reporter's own name would NOT be passed
+   to `--field`):
 
    ```bash
-   echo "Hi I am Jane Smith and my email is jane@example.com" | \
+   echo "I worked with Other Researcher (other@example.com) on this finding" | \
      uv run --project <framework>/tools/privacy-llm/redactor \
      pii-redact \
-     --field reporter:"Jane Smith" \
-     --field email:"jane@example.com"
+     --field name:"Other Researcher" \
+     --field email:"other@example.com"
    ```
 
-   Output should replace the two values with `R-…` and `E-…`
+   Output should replace the two values with `N-…` and `E-…`
    identifiers.
 4. List the resulting map:
 
diff --git a/projects/_template/privacy-llm.md b/projects/_template/privacy-llm.md
index 7a9f03f..696aecf 100644
--- a/projects/_template/privacy-llm.md
+++ b/projects/_template/privacy-llm.md
@@ -73,6 +73,7 @@ the per-variant setup recipes are at
      stack above is fully approved.
 
      `<security-list>` (the project's security@ list) is **not**
-     listed here — its body is treated as non-private; only the
-     reporter PII inside it is redacted (per
+     listed here — its body is treated as non-private; only
+     third-party PII (non-reporter, non-collaborator individuals
+     named in the body) is redacted (per
      ../../tools/privacy-llm/pii.md). -->
diff --git a/tools/privacy-llm/models.md b/tools/privacy-llm/models.md
index 1392d98..fb21138 100644
--- a/tools/privacy-llm/models.md
+++ b/tools/privacy-llm/models.md
@@ -75,8 +75,9 @@ implies.
 ## The pre-flight check
 
 Skills that may read `<private-list>` content (or any private
-content beyond `<security-list>` reporter PII) run this check at
-Step 0:
+content beyond `<security-list>` — recall the redactor handles
+*third-party* PII inside `<security-list>` mail, leaving the
+reporter's own identity intact) run this check at Step 0:
 
 ```text
 1. Read <project-config>/privacy-llm.md, if present.
diff --git a/tools/privacy-llm/pii.md b/tools/privacy-llm/pii.md
index 6496fdf..3aa4be4 100644
--- a/tools/privacy-llm/pii.md
+++ b/tools/privacy-llm/pii.md
@@ -4,6 +4,7 @@
 
 - [PII redaction contract](#pii-redaction-contract)
   - [What counts as PII](#what-counts-as-pii)
+    - [Who counts as a collaborator (not redacted)](#who-counts-as-a-collaborator-not-redacted)
   - [The identifier format](#the-identifier-format)
   - [The mapping store](#the-mapping-store)
   - [The redact-then-reveal lifecycle](#the-redact-then-reveal-lifecycle)
@@ -30,30 +31,80 @@ identifier prefix:
 
 | Field type | Prefix | Sources skills should redact from |
 |---|---|---|
-| Reporter name (or any natural-person name supplied by an external party) | `R-` | `From:` header display name on `<security-list>` mail; signature lines; "I am" / "my name is" patterns in body; CVE credit fields; HackerOne / GHSA reporter names. |
-| Email address | `E-` | `From:` / `Cc:` / `To:` headers; quoted email addresses inside the body; CVE credit `email` fields. |
-| Phone number | `P-` | Signature blocks; "call me at" / "reach me on" patterns. |
-| IP address (v4 or v6) | `IP-` | Reproducer logs; "I tested from" lines. Self-disclosed IPs only — IPs that name a vulnerable production server are **not** PII and stay in place. |
-| Personal handle | `H-` | Personal GitHub username, Twitter handle, IRC nick, Slack handle the reporter self-discloses. The reporter's *public* identity on the GitHub *issue itself* is already public and is not PII; the handle they sign their email with may be different and is. |
-| Postal / employer address (free-form) | `A-` | "I work at" / "my address is" lines. Rare; redact when present. |
+| Third-party name (any natural-person name **other than the reporter** and **other than current `<tracker>` collaborators** — see [Who counts as a collaborator](#who-counts-as-a-collaborator-not-redacted) below) | `N-` | Names appearing in the body / signature lines / CVE credit fields / HackerOne / GHSA fields that the reporter discloses about *other people* (research collaborators, victims they observed, named third parties). |
+| Email address | `E-` | Same scope: emails of the third parties redacted under the row above. The reporter's own `From:` / `Cc:` / `To:` addresses are NOT redacted. |
+| Phone number | `P-` | Signature blocks of third parties; "call me at" / "reach me on" patterns referring to non-reporter individuals. |
+| IP address (v4 or v6) | `IP-` | Reproducer logs; "I tested from" lines. Self-disclosed IPs of third parties only — IPs that name a vulnerable production server are **not** PII and stay in place. |
+| Personal handle | `H-` | Personal GitHub username, Twitter handle, IRC nick, Slack handle of a third party (not the reporter and not a `<tracker>` collaborator). The third party's *public* identity on the GitHub *issue itself* is already public and is not PII; a handle they only signed their email with is. |
+| Postal / employer address (free-form) | `A-` | "I work at" / "my address is" lines referring to non-reporter individuals. |
 
 Things that are **not** PII and the redactor leaves alone:
 
+- **The reporter's own identity** — name, email, phone, signature
+  details. The reporter sent the mail to `<security-list>` and
+  is operationally known to the security team (the team replies
+  to them, credits them in the CVE, references them across the
+  tracker discussion). Their identity flows through the agent's
+  context as-is. *Confidentiality* of the reporter's identity vs
+  *public surfaces* is governed separately by
+  [`AGENTS.md` — Confidentiality of the tracker repository](../../AGENTS.md#confidentiality-of-the-tracker-repository),
+  not by this redactor.
+- **`<tracker>` repo collaborators** — see
+  [Who counts as a collaborator](#who-counts-as-a-collaborator-not-redacted)
+  below. Their identity is already public/known via their
+  collaborator status; redacting them produces no privacy gain.
 - Vulnerability detail (CVEs, CWE numbers, file paths in the
   affected project, code snippets demonstrating the bug).
-- Project maintainer names — the security team's own roster lives
-  in `<project-config>/` and is committed; they are authors, not
+- Project maintainer names — covered by the collaborator
+  exception above. The security team's own roster lives in
+  `<project-config>/` and is committed; they are authors, not
   external parties whose identity needs protecting.
 - Anything inside `<` … `>` that is *already* a placeholder in the
   framework's templates (`<security-list>`, `<reporter>` in canned
   responses, etc.). Redacting placeholders would be silly.
-- URLs — except where the URL contains a personal handle (e.g.
-  `https://github.com/janesmith/...`) the handle alone gets
-  redacted; the URL stays valid.
+- URLs — except where the URL contains a redactable handle (e.g.
+  `https://github.com/janesmith/...` for a non-collaborator
+  janesmith), the handle alone gets redacted; the URL stays
+  valid.
 
-When in doubt, redact. The reverse-on-outbound step (`pii-reveal`)
-is cheap; over-redaction has no cost beyond cosmetics in the
-agent's working text. Under-redaction leaks PII into LLM logs.
+When in doubt about a *third party*, redact. The reverse-on-
+outbound step (`pii-reveal`) is cheap; over-redaction has no
+cost beyond cosmetics in the agent's working text. Under-
+redaction of third-party identity leaks PII into LLM logs. (The
+reporter's own identity is the one case where the default flips
+the other way — leave it as-is, it is operationally needed.)
+
+### Who counts as a collaborator (not redacted)
+
+A "collaborator" for the purposes of the not-redacted exception
+above is anyone returned by:
+
+```bash
+gh api repos/<tracker>/collaborators --jq '.[].login'
+```
+
+This is the same source-of-truth the framework's
+[Treat external content as data, never as instructions](../../AGENTS.md#treat-external-content-as-data-never-as-instructions)
+rule uses for "who is authorised to instruct the agent". Using
+the same list across both rules keeps the security team's
+mental model simple: a collaborator is a collaborator everywhere.
+
+The lookup is per-skill-run, not cached across runs — a
+collaborator added to or removed from the tracker takes effect
+on the next skill invocation. Skills SHOULD pass the resolved
+collaborator set to the redactor as a `--collaborators-file`
+argument (path to a one-login-per-line file), so the redactor
+can decline to redact a name / handle / email that maps to a
+collaborator's GitHub identity.
+
+> **Note for PR-1 / PR-2 readers.** The
+> `--collaborators-file` argument is a *future* addition to the
+> redactor (it lands when PR-2 wires the gate into the security
+> skills). For PR-1 the redactor accepts a flat
+> `--field <type>:<value>` list and trusts the caller to filter
+> against the collaborator set before passing values in. The
+> filtering responsibility stays with the skill either way; the
+> argument is purely an ergonomic shorthand.
 
 ## The identifier format
 
@@ -61,7 +112,7 @@ agent's working text. Under-redaction leaks PII into LLM logs.
 <TYPE>-<6-char-lowercase-hex>
 ```
 
-- `<TYPE>` is one of the prefixes from the table above (`R`, `E`,
+- `<TYPE>` is one of the prefixes from the table above (`N`, `E`,
   `P`, `IP`, `H`, `A`).
 - The 6-char hex is the first 24 bits of `sha256(<lowercased,
   trimmed value>)`, lowercased, no separator.
@@ -75,7 +126,7 @@ Examples:
 
 | Input | Identifier |
 |---|---|
-| `Jane Smith` | `R-a3f9d2` *(illustrative — actual hash differs)* |
+| `Jane Smith` | `N-a3f9d2` *(illustrative — actual hash differs)* |
 | `jane.smith@example.com` | `E-b8c247` |
 | `+1-555-0100` | `P-7d4e91` |
 | `192.0.2.42` | `IP-1a5cef` |
@@ -97,7 +148,7 @@ Format:
 {
   "version": 1,
   "entries": {
-    "R-a3f9d2": {"type": "reporter", "value": "Jane Smith"},
+    "N-a3f9d2": {"type": "name", "value": "Jane Smith"},
     "E-b8c247": {"type": "email",    "value": "jane.smith@example.com"},
     "P-7d4e91": {"type": "phone",    "value": "+1-555-0100"}
   }
@@ -154,7 +205,7 @@ Three rules govern the lifecycle:
    should be a single tool call wide.
 2. **Operate on identifiers throughout.** All intermediate work
    (analysis, summarization, draft composition, prior-art lookup)
-   runs against `R-a3f9d2`-style text. Skills that compose a draft
+   runs against `N-a3f9d2`-style text. Skills that compose a draft
    for the reporter use the identifier in the draft body and only
    reverse it as the last step.
 3. **Reveal only at the outbound boundary.** `pii-reveal` runs
@@ -170,7 +221,7 @@ Two console scripts, both reading stdin and writing stdout:
 ```bash
 # Redact: replace real PII with identifiers; map updated in place.
 echo "$BODY" | uv run --project <framework>/tools/privacy-llm/redactor pii-redact \
-  --field reporter:"Jane Smith" \
+  --field name:"Jane Smith" \
   --field email:"jane.smith@example.com" \
   --field phone:"+1-555-0100"
 
@@ -194,7 +245,7 @@ A third console script lists the current map for debugging:
 
 ```bash
 uv run --project <framework>/tools/privacy-llm/redactor pii-list
-# →  R-a3f9d2  reporter  Jane Smith
+# →  N-a3f9d2  name      Jane Smith
 #    E-b8c247  email     jane.smith@example.com
 #    …
 ```
@@ -205,9 +256,9 @@ placeholder — see
 
 ## Determinism, idempotency, collisions
 
-- **Deterministic** — `pii-redact reporter:"Jane Smith"` produces
-  the same `R-a3f9d2` on every machine, every run, because the
-  identifier is `R-` + first-24-bits of `sha256("jane smith")`.
+- **Deterministic** — `pii-redact name:"Jane Smith"` produces
+  the same `N-a3f9d2` on every machine, every run, because the
+  identifier is `N-` + first-24-bits of `sha256("jane smith")`.
   The mapping file is convenience storage for `pii-reveal`; the
   identifier itself is reproducible without it.
 - **Idempotent** — running `pii-redact` twice on the same input
@@ -217,9 +268,9 @@ placeholder — see
   inputs mapping to the same prefix is a one-in-16-million event
   per (type, prefix) pair. When it happens, the redactor extends
   the second-detected value's hash by 2 hex chars at a time
-  (`R-a3f9d2ab`, then `R-a3f9d2abcd`, …) until the new identifier
+  (`N-a3f9d2ab`, then `N-a3f9d2abcd`, …) until the new identifier
   is unique against the current map. Extension is permanent for
-  that mapping entry — once `R-a3f9d2ab` is in the file, that
+  that mapping entry — once `N-a3f9d2ab` is in the file, that
   value keeps the longer form forever.
 
 ## What never reaches an LLM
diff --git a/tools/privacy-llm/redactor/README.md b/tools/privacy-llm/redactor/README.md
index 4938c06..e96f4fc 100644
--- a/tools/privacy-llm/redactor/README.md
+++ b/tools/privacy-llm/redactor/README.md
@@ -51,16 +51,18 @@ the snapshot path inside an adopting tracker repo):
 
 ```bash
 # Redact: pass --field <type>:<value> for each PII value to swap.
-echo "Hi I am Jane Smith, jane@example.com" | \
+# `name:` is for any natural-person name OTHER than the reporter
+# and other than current <tracker> collaborators (see ../pii.md).
+echo "I worked with Other Researcher (other@example.com) on this finding" | \
   uv run --project tools/privacy-llm/redactor pii-redact \
-    --field reporter:"Jane Smith" \
-    --field email:"jane@example.com"
-# →  Hi I am R-<hex>, E-<hex>
+    --field name:"Other Researcher" \
+    --field email:"other@example.com"
+# →  I worked with N-<hex> (E-<hex>) on this finding
 
 # Reveal: identifiers in stdin → real values from the local map.
-echo "Reply to R-<hex>" | \
+echo "Reply mentions N-<hex>" | \
   uv run --project tools/privacy-llm/redactor pii-reveal
-# →  Reply to Jane Smith
+# →  Reply mentions Other Researcher
 
 # List the current map (text or JSON):
 uv run --project tools/privacy-llm/redactor pii-list
@@ -83,12 +85,12 @@ Field types accepted by `--field`:
 
 | Friendly name | Code | Use for |
 |---|---|---|
-| `reporter` | `R` | Reporter / external party natural-person name. |
-| `email` | `E` | Any email address. |
-| `phone` | `P` | Phone number in any format. |
-| `ip` | `IP` | IPv4 or IPv6 address self-disclosed by the reporter. |
-| `handle` | `H` | Personal social handle (GitHub, Twitter, IRC nick, …). |
-| `address` | `A` | Postal / employer address. |
+| `name` | `N` | Natural-person name of a non-reporter, non-collaborator individual the reporter mentions. |
+| `email` | `E` | Email address of the same. |
+| `phone` | `P` | Phone number of the same. |
+| `ip` | `IP` | IPv4 or IPv6 address self-disclosed by a third party. |
+| `handle` | `H` | Personal social handle (GitHub, Twitter, IRC nick, …) of the same. |
+| `address` | `A` | Postal / employer address of the same. |
 
 ## Mapping file
 
diff --git a/tools/privacy-llm/redactor/src/redactor/mapping.py b/tools/privacy-llm/redactor/src/redactor/mapping.py
index 2151f80..6248d47 100644
--- a/tools/privacy-llm/redactor/src/redactor/mapping.py
+++ b/tools/privacy-llm/redactor/src/redactor/mapping.py
@@ -47,7 +47,7 @@ ENV_MAPPING_PATH = "PII_MAPPING_PATH"
 # are the prefix the identifier carries; the friendly names are
 # what `pii-list` prints.
 TYPE_CODES: dict[str, str] = {
-    "R": "reporter",
+    "N": "name",
     "E": "email",
     "P": "phone",
     "IP": "ip",
@@ -68,7 +68,7 @@ class Entry:
     """One mapping row: identifier → real PII value, with type."""
 
     identifier: str
-    type: str  # friendly type name (e.g. "reporter", "email")
+    type: str  # friendly type name (e.g. "name", "email")
     value: str
 
 
diff --git a/tools/privacy-llm/redactor/src/redactor/redact.py b/tools/privacy-llm/redactor/src/redactor/redact.py
index b8b1596..e6def58 100644
--- a/tools/privacy-llm/redactor/src/redactor/redact.py
+++ b/tools/privacy-llm/redactor/src/redactor/redact.py
@@ -57,7 +57,7 @@ def parse_field(spec: str) -> tuple[str, str]:
     # Accept friendly name first, then code.
     if type_part.lower() in TYPE_NAMES:
         return TYPE_NAMES[type_part.lower()], value
-    if type_part.upper() in {"R", "E", "P", "IP", "H", "A"}:
+    if type_part.upper() in {"N", "E", "P", "IP", "H", "A"}:
         return type_part.upper(), value
     raise SystemExit(
         f"unknown field type {type_part!r}. "
diff --git a/tools/privacy-llm/redactor/tests/test_list_cmd.py b/tools/privacy-llm/redactor/tests/test_list_cmd.py
index d9ce29f..fa199d9 100644
--- a/tools/privacy-llm/redactor/tests/test_list_cmd.py
+++ b/tools/privacy-llm/redactor/tests/test_list_cmd.py
@@ -30,7 +30,7 @@ def _seed(path: pathlib.Path) -> None:
     save_mapping_atomic(
         path,
         {
-            "R-abcdef": Entry(identifier="R-abcdef", type="reporter", value="Jane Smith"),
+            "N-abcdef": Entry(identifier="N-abcdef", type="name", value="Jane Smith"),
             "E-fedcba": Entry(identifier="E-fedcba", type="email", value="jane@example.com"),
         },
     )
@@ -63,8 +63,8 @@ def test_list_json_format(tmp_path: pathlib.Path, monkeypatch):
     assert rc == 0
     payload = json.loads(out)
     assert payload["version"] == 1
-    assert "R-abcdef" in payload["entries"]
-    assert payload["entries"]["R-abcdef"]["value"] == "Jane Smith"
+    assert "N-abcdef" in payload["entries"]
+    assert payload["entries"]["N-abcdef"]["value"] == "Jane Smith"
 
 
 def test_list_empty_text(tmp_path: pathlib.Path, monkeypatch):
diff --git a/tools/privacy-llm/redactor/tests/test_mapping.py b/tools/privacy-llm/redactor/tests/test_mapping.py
index 01b8ae8..8afec1a 100644
--- a/tools/privacy-llm/redactor/tests/test_mapping.py
+++ b/tools/privacy-llm/redactor/tests/test_mapping.py
@@ -57,9 +57,9 @@ def test_hash_value_is_deterministic_across_case_and_whitespace():
 
 
 def test_generate_identifier_default_length():
-    ident, extended = generate_identifier("R", "Jane Smith", existing={})
+    ident, extended = generate_identifier("N", "Jane Smith", existing={})
     prefix, hex_part = ident.split("-", 1)
-    assert prefix == "R"
+    assert prefix == "N"
     assert len(hex_part) == DEFAULT_HEX_LEN
     assert all(c in "0123456789abcdef" for c in hex_part)
     assert extended is False
@@ -67,40 +67,40 @@ def test_generate_identifier_default_length():
 
 def test_generate_identifier_idempotent_for_same_value():
     existing: dict[str, Entry] = {}
-    ident_a, _ = generate_identifier("R", "Jane Smith", existing)
+    ident_a, _ = generate_identifier("N", "Jane Smith", existing)
     # Idempotency check needs the entry to exist in the map.
-    existing[ident_a] = Entry(identifier=ident_a, type="reporter", value="Jane Smith")
-    ident_b, _ = generate_identifier("R", "Jane Smith", existing)
+    existing[ident_a] = Entry(identifier=ident_a, type="name", value="Jane Smith")
+    ident_b, _ = generate_identifier("N", "Jane Smith", existing)
     assert ident_a == ident_b
 
 
 def test_generate_identifier_idempotent_normalises_input():
     """Same value with different case / whitespace gets the same identifier."""
     existing: dict[str, Entry] = {}
-    ident_a, _ = generate_identifier("R", "Jane Smith", existing)
-    existing[ident_a] = Entry(identifier=ident_a, type="reporter", value="Jane Smith")
-    ident_b, _ = generate_identifier("R", "  jane smith  ", existing)
+    ident_a, _ = generate_identifier("N", "Jane Smith", existing)
+    existing[ident_a] = Entry(identifier=ident_a, type="name", value="Jane Smith")
+    ident_b, _ = generate_identifier("N", "  jane smith  ", existing)
     assert ident_a == ident_b
 
 
 def test_generate_identifier_extends_on_collision():
     """Force a collision by pre-seeding the existing map."""
     # Compute the natural identifier for "Jane Smith".
-    natural_ident, _ = generate_identifier("R", "Jane Smith", existing={})
+    natural_ident, _ = generate_identifier("N", "Jane Smith", existing={})
     # Pretend that identifier is already taken by a *different*
     # value (idempotency check is by value, so a different value
     # at the same identifier triggers extension for the new one).
     pre_seeded = {
         natural_ident: Entry(
             identifier=natural_ident,
-            type="reporter",
+            type="name",
             value="some other reporter",
         ),
     }
-    new_ident, extended = generate_identifier("R", "Jane Smith", pre_seeded)
+    new_ident, extended = generate_identifier("N", "Jane Smith", pre_seeded)
     assert extended is True
     assert new_ident != natural_ident
-    assert new_ident.startswith("R-")
+    assert new_ident.startswith("N-")
     # Extended hex is longer than the default.
     _prefix, hex_part = new_ident.split("-", 1)
     assert len(hex_part) > DEFAULT_HEX_LEN
@@ -116,21 +116,21 @@ def test_generate_identifier_rejects_unknown_type():
 
 def test_upsert_creates_then_returns_existing():
     mapping: dict[str, Entry] = {}
-    a = upsert(mapping, "R", "Jane Smith")
-    b = upsert(mapping, "R", "Jane Smith")
+    a = upsert(mapping, "N", "Jane Smith")
+    b = upsert(mapping, "N", "Jane Smith")
     assert a is b or a == b
     assert len(mapping) == 1
     assert a.value == "Jane Smith"
-    assert a.type == "reporter"
+    assert a.type == "name"
 
 
 def test_upsert_distinguishes_types_for_same_value():
     """A reporter named ``foo`` and an email ``foo`` get distinct entries."""
     mapping: dict[str, Entry] = {}
-    r = upsert(mapping, "R", "foo")
+    r = upsert(mapping, "N", "foo")
     e = upsert(mapping, "E", "foo")
     assert r.identifier != e.identifier
-    assert r.identifier.startswith("R-")
+    assert r.identifier.startswith("N-")
     assert e.identifier.startswith("E-")
 
 
@@ -140,7 +140,7 @@ def test_upsert_distinguishes_types_for_same_value():
 def test_save_and_load_round_trip(tmp_path: pathlib.Path):
     path = tmp_path / "pii.json"
     mapping: dict[str, Entry] = {}
-    upsert(mapping, "R", "Jane Smith")
+    upsert(mapping, "N", "Jane Smith")
     upsert(mapping, "E", "jane@example.com")
 
     save_mapping_atomic(path, mapping)
@@ -151,7 +151,7 @@ def test_save_and_load_round_trip(tmp_path: pathlib.Path):
 def test_save_creates_parent_dir(tmp_path: pathlib.Path):
     path = tmp_path / "deeper" / "nested" / "pii.json"
     mapping: dict[str, Entry] = {}
-    upsert(mapping, "R", "Jane Smith")
+    upsert(mapping, "N", "Jane Smith")
     save_mapping_atomic(path, mapping)
     assert path.exists()
 
@@ -159,7 +159,7 @@ def test_save_creates_parent_dir(tmp_path: pathlib.Path):
 def test_save_writes_mode_0o600(tmp_path: pathlib.Path):
     path = tmp_path / "pii.json"
     mapping: dict[str, Entry] = {}
-    upsert(mapping, "R", "Jane Smith")
+    upsert(mapping, "N", "Jane Smith")
     save_mapping_atomic(path, mapping)
     mode = path.stat().st_mode & 0o777
     assert mode == 0o600
@@ -168,7 +168,7 @@ def test_save_writes_mode_0o600(tmp_path: pathlib.Path):
 def test_save_serialises_with_version_and_entries(tmp_path: pathlib.Path):
     path = tmp_path / "pii.json"
     mapping: dict[str, Entry] = {}
-    upsert(mapping, "R", "Jane Smith")
+    upsert(mapping, "N", "Jane Smith")
     save_mapping_atomic(path, mapping)
     raw = json.loads(path.read_text())
     assert raw["version"] == MAPPING_VERSION
@@ -199,7 +199,7 @@ def test_load_rejects_malformed_entry(tmp_path: pathlib.Path):
         json.dumps(
             {
                 "version": MAPPING_VERSION,
-                "entries": {"R-abcdef": {"type": "reporter"}},  # missing value
+                "entries": {"N-abcdef": {"type": "name"}},  # missing value
             }
         )
     )
diff --git a/tools/privacy-llm/redactor/tests/test_redact.py b/tools/privacy-llm/redactor/tests/test_redact.py
index b70b093..2cebc03 100644
--- a/tools/privacy-llm/redactor/tests/test_redact.py
+++ b/tools/privacy-llm/redactor/tests/test_redact.py
@@ -50,11 +50,11 @@ def _run(monkeypatch, stdin_text: str, argv: list[str]) -> tuple[str, int]:
 
 
 def test_parse_field_friendly_name():
-    assert redact.parse_field("reporter:Jane Smith") == ("R", "Jane Smith")
+    assert redact.parse_field("name:Jane Smith") == ("N", "Jane Smith")
 
 
 def test_parse_field_code():
-    assert redact.parse_field("R:Jane Smith") == ("R", "Jane Smith")
+    assert redact.parse_field("N:Jane Smith") == ("N", "Jane Smith")
 
 
 def test_parse_field_value_can_contain_colon():
@@ -74,7 +74,7 @@ def test_parse_field_rejects_unknown_type():
 
 def test_parse_field_rejects_empty_value():
     with pytest.raises(SystemExit, match="value is empty"):
-        redact.parse_field("reporter:")
+        redact.parse_field("name:")
 
 
 # -- end-to-end redaction ------------------------------------------------
@@ -85,12 +85,12 @@ def test_redact_replaces_values_with_identifiers(mapping_path, monkeypatch):
     out, rc = _run(
         monkeypatch,
         body,
-        ["--field", "reporter:Jane Smith", "--field", "email:jane@example.com"],
+        ["--field", "name:Jane Smith", "--field", "email:jane@example.com"],
     )
     assert rc == 0
     assert "Jane Smith" not in out
     assert "jane@example.com" not in out
-    assert "R-" in out
+    assert "N-" in out
     assert "E-" in out
 
 
@@ -98,21 +98,21 @@ def test_redact_persists_mapping(mapping_path, monkeypatch):
     _, rc = _run(
         monkeypatch,
         "Jane Smith",
-        ["--field", "reporter:Jane Smith"],
+        ["--field", "name:Jane Smith"],
     )
     assert rc == 0
     mapping = load_mapping(mapping_path)
     # Exactly one entry, of type reporter, value "Jane Smith".
     assert len(mapping) == 1
     [entry] = mapping.values()
-    assert entry.type == "reporter"
+    assert entry.type == "name"
     assert entry.value == "Jane Smith"
 
 
 def test_redact_idempotent_across_runs(mapping_path, monkeypatch):
     """Running redact twice with the same field produces the same identifier."""
-    out_a, _ = _run(monkeypatch, "Jane Smith", ["--field", "reporter:Jane Smith"])
-    out_b, _ = _run(monkeypatch, "Jane Smith", ["--field", "reporter:Jane Smith"])
+    out_a, _ = _run(monkeypatch, "Jane Smith", ["--field", "name:Jane Smith"])
+    out_b, _ = _run(monkeypatch, "Jane Smith", ["--field", "name:Jane Smith"])
     assert out_a == out_b
     assert len(load_mapping(mapping_path)) == 1
 
@@ -127,7 +127,7 @@ def test_redact_no_fields_passes_input_through(mapping_path, monkeypatch):
 
 def test_redact_replaces_all_occurrences(mapping_path, monkeypatch):
     body = "Jane Smith said. Then Jane Smith left. Finally Jane Smith returned."
-    out, _ = _run(monkeypatch, body, ["--field", "reporter:Jane Smith"])
+    out, _ = _run(monkeypatch, body, ["--field", "name:Jane Smith"])
     assert "Jane Smith" not in out
     # Exactly three identifiers in the output.
     mapping = load_mapping(mapping_path)
@@ -147,21 +147,21 @@ def test_redact_substring_safety(mapping_path, monkeypatch):
     out, _ = _run(
         monkeypatch,
         body,
-        ["--field", "reporter:Jane", "--field", "email:jane@example.com"],
+        ["--field", "name:Jane", "--field", "email:jane@example.com"],
     )
-    # The email's identifier should be intact (not contain "R-").
+    # The email's identifier should be intact (not contain "N-").
     mapping = load_mapping(mapping_path)
     email_entry = next(e for e in mapping.values() if e.type == "email")
     assert email_entry.identifier in out
     # The standalone "Jane" should be redacted.
-    reporter_entry = next(e for e in mapping.values() if e.type == "reporter")
+    reporter_entry = next(e for e in mapping.values() if e.type == "name")
     assert reporter_entry.identifier in out
 
 
 def test_redact_missing_value_in_input_is_silent(mapping_path, monkeypatch):
     """Declaring a ``--field`` whose value is absent from stdin is a no-op on the text."""
     body = "no PII here"
-    out, _ = _run(monkeypatch, body, ["--field", "reporter:Absent Person"])
+    out, _ = _run(monkeypatch, body, ["--field", "name:Absent Person"])
     assert out == "no PII here"
     # The value was still recorded in the mapping (skill's
     # responsibility to declare; we trust the declaration).
@@ -175,5 +175,5 @@ def test_redact_returns_2_on_malformed_mapping_file(mapping_path, monkeypatch):
     monkeypatch.setattr("sys.stdin", io.StringIO("ignored"))
     monkeypatch.setattr("sys.stdout", io.StringIO())
     monkeypatch.setattr("sys.stderr", io.StringIO())
-    rc = redact.main(["--field", "reporter:Jane Smith"])
+    rc = redact.main(["--field", "name:Jane Smith"])
     assert rc == 2
diff --git a/tools/privacy-llm/redactor/tests/test_reveal.py b/tools/privacy-llm/redactor/tests/test_reveal.py
index d102b31..91ce4fb 100644
--- a/tools/privacy-llm/redactor/tests/test_reveal.py
+++ b/tools/privacy-llm/redactor/tests/test_reveal.py
@@ -36,7 +36,7 @@ def seeded_mapping(tmp_path: pathlib.Path, monkeypatch) -> Iterator[pathlib.Path
     # Seed with one reporter and one email.
     monkeypatch.setattr("sys.stdin", io.StringIO("Jane Smith jane@example.com"))
     monkeypatch.setattr("sys.stdout", io.StringIO())
-    redact.main(["--field", "reporter:Jane Smith", "--field", "email:jane@example.com"])
+    redact.main(["--field", "name:Jane Smith", "--field", "email:jane@example.com"])
     yield path
 
 
@@ -54,7 +54,7 @@ def _reveal(monkeypatch, stdin_text: str) -> tuple[str, int]:
 
 def test_reveal_substitutes_known_identifiers(seeded_mapping, monkeypatch):
     mapping = load_mapping(seeded_mapping)
-    reporter = next(e for e in mapping.values() if e.type == "reporter")
+    reporter = next(e for e in mapping.values() if e.type == "name")
     email = next(e for e in mapping.values() if e.type == "email")
     text = f"Hi {reporter.identifier}, your email {email.identifier} was confirmed."
     out, rc = _reveal(monkeypatch, text)
@@ -71,7 +71,7 @@ def test_reveal_leaves_unknown_identifiers_intact(seeded_mapping, monkeypatch):
     This is the cross-machine / lost-mapping case — we have no
     way to reverse it, so we leave the bytes alone.
     """
-    text = "Reply to R-deadbeef please."
+    text = "Reply to N-deadbeef please."
     out, _ = _reveal(monkeypatch, text)
     assert out == text
 
@@ -86,7 +86,7 @@ def test_reveal_does_not_match_arbitrary_uppercase_dash_hex(seeded_mapping, monk
 def test_reveal_handles_identifier_inside_punctuation(seeded_mapping, monkeypatch):
     """Identifiers next to punctuation should still be revealed."""
     mapping = load_mapping(seeded_mapping)
-    reporter = next(e for e in mapping.values() if e.type == "reporter")
+    reporter = next(e for e in mapping.values() if e.type == "name")
     text = f"({reporter.identifier})"
     out, _ = _reveal(monkeypatch, text)
     assert out == "(Jane Smith)"
@@ -95,7 +95,7 @@ def test_reveal_handles_identifier_inside_punctuation(seeded_mapping, monkeypatc
 def test_reveal_empty_mapping_passes_through(tmp_path: pathlib.Path, monkeypatch):
     """No mapping file → identifier-shaped tokens are left as-is."""
     monkeypatch.setenv("PII_MAPPING_PATH", str(tmp_path / "missing.json"))
-    text = "Hi R-abcdef, your email E-deadbe was confirmed."
+    text = "Hi N-abcdef, your email E-deadbe was confirmed."
     out, rc = _reveal(monkeypatch, text)
     assert rc == 0
     assert out == text
@@ -109,7 +109,7 @@ def test_reveal_round_trip_with_redact(tmp_path: pathlib.Path, monkeypatch):
     monkeypatch.setattr("sys.stdin", io.StringIO(body))
     redacted_buf = io.StringIO()
     monkeypatch.setattr("sys.stdout", redacted_buf)
-    redact.main(["--field", "reporter:Jane Smith", "--field", "email:jane@example.com"])
+    redact.main(["--field", "name:Jane Smith", "--field", "email:jane@example.com"])
     # Reveal pass.
     monkeypatch.setattr("sys.stdin", io.StringIO(redacted_buf.getvalue()))
     revealed_buf = io.StringIO()
@@ -126,9 +126,9 @@ def test_reveal_function_directly():
     from redactor.mapping import Entry
 
     mapping = {
-        "R-abcdef": Entry(identifier="R-abcdef", type="reporter", value="Jane Smith"),
+        "N-abcdef": Entry(identifier="N-abcdef", type="name", value="Jane Smith"),
     }
-    assert reveal.reveal("Hello R-abcdef.", mapping) == "Hello Jane Smith."
+    assert reveal.reveal("Hello N-abcdef.", mapping) == "Hello Jane Smith."
 
 
 def test_reveal_returns_2_on_malformed_mapping_file(tmp_path: pathlib.Path, monkeypatch):
diff --git a/tools/privacy-llm/tool.md b/tools/privacy-llm/tool.md
index 45109b5..b9b3c43 100644
--- a/tools/privacy-llm/tool.md
+++ b/tools/privacy-llm/tool.md
@@ -47,7 +47,7 @@ agent's context, and `privacy-llm` is what stops it from leaking.
 
 | Capability | File | What it covers |
 |---|---|---|
-| PII redaction contract | [`pii.md`](pii.md) | Which fields are PII, the hash-prefixed identifier format (`R-a3f9d2`, `E-b8c247`, …), the local mapping store at `~/.config/apache-steward/pii-mapping.json`, the redact-then-reveal lifecycle. |
+| PII redaction contract | [`pii.md`](pii.md) | Which fields are PII, the hash-prefixed identifier format (`N-a3f9d2`, `E-b8c247`, …), the local mapping store at `~/.config/apache-steward/pii-mapping.json`, the redact-then-reveal lifecycle. |
 | Approved-LLM registry | [`models.md`](models.md) | Which LLMs the framework treats as privacy-approved (Claude Code by default; anything at `*.apache.org`; local Ollama / vLLM; everything else opt-in), how to declare additions in `<project-config>/privacy-llm.md`, and what the pre-flight gate checks. |
 | Setup recipes | [`../../docs/setup/privacy-llm.md`](../../docs/setup/privacy-llm.md) | Copy-pasteable configurations for the supported variants — local inference, Apache-hosted endpoint, AWS Bedrock, opt-in third-party. Marked **provisional pending ASF Legal Affairs ratification** of an authoritative approved-model list. |
 | Reference Python helper | [`redactor/`](redactor/) | A small `uv` project exposing three console scripts — `pii-redact`, `pii-reveal`, `pii-list` — that skills shell out to so the redaction lifecycle is consistent across every consumer. |
@@ -74,10 +74,10 @@ classes and run at different points in the pipeline:
 
 | Data class | Source | What `privacy-llm` does | Gate runs at |
 |---|---|---|---|
-| `<security-list>` body without PII | Gmail / PonyMail public archive | Body flows to any LLM (including Claude). No gate. | n/a |
-| `<security-list>` reporter PII | Gmail / PonyMail | **Always redacted** — name, email, phone, IP, personal handle replaced with hash-prefixed identifiers. Mapping kept local; never sent to any LLM. | Immediately after fetch, before any further processing. |
+| `<security-list>` body — reporter's own PII | Gmail / PonyMail public archive | **Not redacted.** The reporter sent the mail and is operationally known to the security team (CVE credit, reply threads, etc.). Their identity flows through the agent's context as-is. | n/a |
+| `<security-list>` body — third-party PII | Gmail / PonyMail | **Redacted** — names, emails, phones, IPs, personal handles of *non-reporter, non-collaborator* individuals replaced with hash-prefixed identifiers (`N-…`, `E-…`, …). Collaborators on `<tracker>` (`gh api repos/<tracker>/collaborators`) are exempt — already public/known. Mapping kept local; never sent to any LLM. | Immediately after fetch, before any further processing. |
 | `<private-list>` content | Gmail / PonyMail (PMC-private archive) | **Pre-flight gate** — refuse to fetch unless the active LLM stack is in the approved-model registry. No redaction (the body is private as a whole). | Step 0 pre-flight on every skill that may read a `<private-list>` thread. |
-| Outbound drafts to the reporter | Skill draft assembly | Reverse identifiers → real names just before the draft is written. | Final assembly, after the LLM step that composed the draft body. |
+| Outbound drafts that reference redacted third parties | Skill draft assembly | Reverse identifiers → real names just before the draft is written (only for third-party identifiers actually referenced in the draft). | Final assembly, after the LLM step that composed the draft body. |
 
 The decision tree the skill follows on every fetch is captured in
 [`pii.md`](pii.md) (redaction lifecycle) and [`models.md`](models.md)
@@ -119,9 +119,10 @@ Concrete invocation patterns are in
   delegated-summarization hops). Both apply; they are layered.
 - **Not a content classifier.** The redactor doesn't try to *guess*
   which strings are PII — skills hand it the field values
-  explicitly (reporter name from the parsed `From:` header, email
-  from the same, etc.). PII discovery is the skill's job; redaction
-  is the redactor's job.
+  explicitly (third-party names parsed out of the body, emails
+  from quoted headers other than the reporter's own, etc.). PII
+  discovery + the reporter-vs-third-party + collaborator-vs-not
+  filtering is the skill's job; redaction is the redactor's job.
 - **Not an MCP-layer interception.** Claude Code's MCP runtime does
   not (yet) support per-tool transformation hooks. The redactor
   runs as an explicit step *inside the skill*, not as a transparent
@@ -134,6 +135,6 @@ Concrete invocation patterns are in
 | Symptom | Likely cause | Remediation |
 |---|---|---|
 | Skill refuses to run with "no approved privacy LLM configured" | Adopter has not yet written `<project-config>/privacy-llm.md`, or it lists no approved entries | Follow [`docs/setup/privacy-llm.md`](../../docs/setup/privacy-llm.md) — the default `Claude Code` entry is enough for the local-only case |
-| `pii-reveal` returns text with `R-a3f9d2`-style identifiers still in place | The mapping file at `~/.config/apache-steward/pii-mapping.json` was deleted, truncated, or moved | Re-fetch the source; the redactor regenerates identifiers deterministically from the raw values, but it cannot reverse identifiers it has no mapping for |
+| `pii-reveal` returns text with `N-a3f9d2`-style identifiers still in place | The mapping file at `~/.config/apache-steward/pii-mapping.json` was deleted, truncated, or moved | Re-fetch the source; the redactor regenerates identifiers deterministically from the raw values, but it cannot reverse identifiers it has no mapping for |
 | `pii-redact` produces different identifiers across runs | Identifier format was changed (the framework bumped the hash length, or the prefix scheme) — see the version field in `pii-mapping.json` | Migration logic lives in the next framework version's release notes; until then keep the mapping file pinned |
 | Skill is meant to read `<security-list>` but is being gated by the approved-model pre-flight | Adopter has incorrectly classified `<security-list>` as private in `<project-config>/privacy-llm.md` | Remove `<security-list>` from the private-list set; PII redaction (which IS required for `<security-list>`) is independent of the gate |