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 |