<!-- SPDX-License-Identifier: Apache-2.0
     https://www.apache.org/licenses/LICENSE-2.0 -->

<!-- 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)*

- [RFC-AI-0003: Privacy-aware LLM routing for foundation private information](#rfc-ai-0003-privacy-aware-llm-routing-for-foundation-private-information)
- [RFC: privacy-aware LLM routing for foundation private mail](#rfc-privacy-aware-llm-routing-for-foundation-private-mail)
  - [1. Abstract](#1-abstract)
  - [2. Background and motivation](#2-background-and-motivation)
  - [3. Goals and non-goals](#3-goals-and-non-goals)
    - [Goals](#goals)
    - [Non-goals](#non-goals)
  - [4. Design](#4-design)
    - [4.1 The two mechanisms at a glance](#41-the-two-mechanisms-at-a-glance)
    - [4.2 Mechanism 1 — PII redactor](#42-mechanism-1--pii-redactor)
    - [4.3 Mechanism 2 — approved-LLM gate](#43-mechanism-2--approved-llm-gate)
    - [4.4 Mechanism 3 (defence-in-depth) — egress-allowlist gateway](#44-mechanism-3-defence-in-depth--egress-allowlist-gateway)
  - [5. Data flow](#5-data-flow)
  - [6. Implementation](#6-implementation)
    - [6.1 The redactor sub-tool — `tools/privacy-llm/redactor/`](#61-the-redactor-sub-tool--toolsprivacy-llmredactor)
    - [6.2 The checker sub-tool — `tools/privacy-llm/checker/` (PR #51)](#62-the-checker-sub-tool--toolsprivacy-llmchecker-pr-51)
    - [6.3 What never reaches any LLM](#63-what-never-reaches-any-llm)
    - [6.4 The egress gateway — `tools/egress-gateway/`](#64-the-egress-gateway--toolsegress-gateway)
  - [7. Adopter configuration](#7-adopter-configuration)
  - [8. Skill wiring summary](#8-skill-wiring-summary)
  - [9. Trust boundaries and status](#9-trust-boundaries-and-status)
  - [10. Open questions and future work](#10-open-questions-and-future-work)
    - [10.1 Resolved in PR-3](#101-resolved-in-pr-3)
    - [10.2 ASF Privacy VP/Legal VP ratification](#102-asf-privacy-vplegal-vp-ratification)
    - [10.3 MCP-layer hooks](#103-mcp-layer-hooks)
    - [10.4 Mapping-file lifecycle tools](#104-mapping-file-lifecycle-tools)
    - [10.5 Doc-cleanup follow-up](#105-doc-cleanup-follow-up)
    - [10.6 Egress-gateway wiring](#106-egress-gateway-wiring)
  - [11. References](#11-references)

<!-- 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 -->

<!-- Source: ASF Confluence wiki (RFCs space). Public-safe re-export:
     wiki-internal links and members-only references have been stripped
     per the Apache Magpie project's RFC-AI-0004 § Privacy-by-Design
     principle (no exposing of SSO-gated URLs in public artefacts).
     The authoritative source remains the Confluence page; this file
     is a public mirror for review by adopters who do not have ASF SSO. -->

# RFC-AI-0003: Privacy-aware LLM routing for foundation private information

Apache Magpie — third-party PII redaction + approved-LLM gate

apache/magpie maintainers

2026-05-04

**Status — provisional.** The default-approved LLM registry described in this RFC reflects the framework maintainer's working position pending ASF Privacy VP/Legal VP ratification of an authoritative approved-model list for foundation private data. When such a list lands, the registry will be updated to point at it as source-of-truth.

# RFC: privacy-aware LLM routing for foundation private mail

| Field | Value |
|---|---|
| **Status** | Provisional — pending ASF Privacy VP/Legal VP ratification |
| **Targets** | `apache/magpie` (the Apache Magpie framework) + adopting projects |
| **Implemented in** | [PR #48](https://github.com/apache/magpie/pull/48) (foundation), [PR #50](https://github.com/apache/magpie/pull/50) (refinement + skill-side redactor wiring), [PR #51](https://github.com/apache/magpie/pull/51) (gate-check + skill-side gate wiring) |
| **Source-of-truth docs** | [`tools/privacy-llm/{tool,pii,models,wiring}.md`](https://github.com/apache/magpie/tree/main/tools/privacy-llm), [`docs/setup/privacy-llm.md`](https://github.com/apache/magpie/blob/main/docs/setup/privacy-llm.md), [`AGENTS.md → Privacy-LLM`](https://github.com/apache/magpie/blob/main/AGENTS.md) |
| **Reference implementation** | [`tools/privacy-llm/redactor/`](https://github.com/apache/magpie/tree/main/tools/privacy-llm/redactor) (PII redactor, stdlib-only Python, 48 unit tests), [`tools/privacy-llm/checker/`](https://github.com/apache/magpie/tree/main/tools/privacy-llm/checker) (approved-LLM gate-check, stdlib-only Python, 33 unit tests) |

## 1. Abstract

The Apache Magpie framework lets agents drive ASF security workflows that read **two distinct classes of private mail**: external reporters' mail to a project's `<security-list>` and PMC-internal mail on `<private-list>`. Both classes must not leak through any LLM in the active stack — but they require **different** remediations, and a single conflated mechanism would either over-block (refuse to process `<security-list>` content needlessly) or under-protect (let `<private-list>` bodies flow through arbitrary LLMs).

This RFC describes — and the linked PRs implement — a **two-mechanism design**:

1. A **PII redactor** that swaps third-party identifiers in `<security-list>` mail for hash-prefixed tokens before any LLM step, with a deterministic local mapping that is reversed only at the outbound boundary.
2. An **approved-LLM pre-flight gate** that refuses to fetch `<private-list>` content unless every LLM in the active stack matches the framework's default-approved registry or an adopter-declared opt-in entry.

The reporter's own identity flows through the agent's context as-is, by design — they sent the mail and are operationally known to the security team. Collaborators on the project's `<tracker>` repo are similarly exempt: their identity is already public via collaborator status.

Both mechanisms are now landed: the redactor (PR #48 + PR #50) and the gate-check (PR #51). The full design is shipped, end-to-end, behind explicit Step 0 pre-flight calls in every `<security-list>`-touching skill.

**Complementary network-layer control.** The two mechanisms above operate at the application layer — they decide what a skill deliberately *sends* to an LLM. They do not, by themselves, stop private data from leaving over an arbitrary HTTP call (a buggy tool, or a prompt-injection payload that coaxes the agent into exfiltration). §4.4 adds an optional **egress-allowlist gateway** (`tools/egress-gateway/`) as defence-in-depth: a default-deny host allowlist that funnels all tool egress through a single chokepoint, so private data physically cannot reach a non-sanctioned host even if a higher layer is bypassed. It is layered *under* the two LLM-routing mechanisms, not a replacement for them.

## 2. Background and motivation

ASF security work routinely handles two kinds of private content:

- **`<security-list>` mail.** External reporters send vulnerability reports to the project's `security@` list. The reporter is a known correspondent (the team replies, attributes credit, and references them across the tracker discussion). The body, however, frequently mentions **third parties** — a co-researcher, a victim the reporter observed, a named individual called out in the body — whose identities are not operationally needed by the security team and which absolutely should not flow through arbitrary LLMs.
- **`<private-list>` mail.** PMC-private foundation lists (`private@<project>.apache.org` and any cross-project relay lists the security team subscribes to) are wholly private. Every byte — body and metadata alike — is sensitive.

A single mechanism cannot serve both. PII redaction is necessary but insufficient for `<private-list>` (the body itself is private, not just the identifiers). An approved-LLM gate alone is insufficient for `<security-list>` (an LLM may be approved to receive the body but third-party PII still belongs in the local map, not in any LLM's context window or inference logs).

Two earlier candidate designs were rejected:

- **"Just redact everything before any LLM call."** This loses the reporter's identity, which the team needs operationally for replies, CVE credit, and cross-skill handoff. It also creates a much larger attack surface for over-redaction false positives in code excerpts (CVEs, IPs that identify production servers, etc.).
- **"Just gate every LLM call against an allowlist."** This misses third-party PII inside `<security-list>` content — that mail is allowed through approved LLMs, but the third-party identifiers are still in the body unredacted.

The two-mechanism design lets each remedy do what it is good at, and explicitly separates *what is gated* from *what is redacted*.

## 3. Goals and non-goals

### Goals

- **G1.** Third-party PII in `<security-list>` mail never enters an LLM's context in the clear.
- **G2.** `<private-list>` content cannot reach any LLM the adopter has not explicitly approved.
- **G3.** The reporter's own identity continues to flow through normally — replies, CVE credits, sync comments work as before.
- **G4.** Adopting a stricter posture (e.g. "redact collaborators too") is a single config flip, not a code change.
- **G5.** Adding a new LLM hop (summariser, classifier, delegated-analysis) is a deliberate act with PMC sign-off, not something a skill can grow into silently.
- **G6.** The mechanism is **cross-cutting**: hosting it under `tools/gmail/` would couple it to one fetch backend, hosting it under any single skill would create N drifting copies. A dedicated `tools/privacy-llm/` directory keeps the contract in one place.

### Non-goals

- **N1.** A content classifier. The redactor does not guess which strings are PII; the calling skill identifies them explicitly via `--field <type>:<value>` arguments.
- **N2.** A replacement for the existing public-surface confidentiality rules in [`AGENTS.md`](https://github.com/apache/magpie/blob/main/AGENTS.md). Those govern human-visible surfaces (public PRs, public issue comments, public mail replies); privacy-llm governs machine-routed surfaces. **Both apply, layered.**
- **N3.** An MCP-layer interception. Claude Code's MCP runtime does not (yet) support per-tool transformation hooks, so the redactor and gate-check run as explicit steps inside the skill. If a future MCP gains hook support, the call points can move into the hook without changing the contract.
- **N4.** A ratified ASF-wide approved-model registry. The default-approved list reflects the framework maintainer's working position pending ASF Privacy VP/Legal VP guidance; see §9.

## 4. Design

### 4.1 The two mechanisms at a glance

| Data class | Source | What `privacy-llm` does | Gate runs at |
|---|---|---|---|
| `<security-list>` body — reporter's own PII | Gmail / PonyMail public archive | **Not redacted.** Reporter is operationally known; identity flows through 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 `N-…`, `E-…`, `P-…`, `IP-…`, `H-…`, `A-…` identifiers. 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 every entry in 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 referencing redacted third parties | Skill draft assembly | Reverse identifiers → real values just before the draft is written, only for identifiers actually referenced in the draft. | Final assembly, after the LLM step that composed the draft body. |

### 4.2 Mechanism 1 — PII redactor

#### Field types and identifier shape

| Field | Code | Identifier example | Sources skills should redact |
|---|---|---|---|
| Third-party name | `N` | `N-a3f9d2` | Body, signature lines, CVE credit fields, HackerOne/GHSA fields — names of non-reporter, non-collaborator individuals. |
| Email address | `E` | `E-b8c247` | Same scope. Reporter's own `From:` is not redacted. |
| Phone number | `P` | `P-7d4e91` | Third-party signature blocks; "call me at" patterns. |
| IP address (v4/v6) | `IP` | `IP-1a5cef` | Reproducer logs; "I tested from" lines. **Not** IPs that identify a vulnerable production server. |
| Personal handle | `H` | `H-9e3b04` | Personal GitHub/Twitter/IRC/Slack handles of third parties (not the reporter, not collaborators). |
| Postal/employer address | `A` | `A-…` | "I work at"/"my address is" lines referring to non-reporter individuals. |

The identifier format is `<TYPE>-<6-char-lowercase-hex>` where the hex is `sha256(value.strip().lower())[:24-bits]`. The 6-char default gives ~16M slots before collision pressure becomes meaningful — comfortably above any single ASF project's lifetime PII volume. On collision (two distinct values hashing to the same prefix), the second-detected value's hex is extended in 8-bit increments (`N-a3f9d2ab`, `N-a3f9d2abcd`, …) until the new identifier is unique. Extension is permanent for that mapping entry.

#### Determinism and idempotency

- **Deterministic.** `pii-redact name:"Jane Smith"` produces the same `N-…` on every machine and every run, because the identifier is derived from a normalised hash of the value. 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 with the same `--field` values writes the mapping file once and produces identical output the second time.
- **Cross-machine compatible.** Two contributors redacting the same body produce the same identifier text without sharing the mapping file. Reveal is per-machine: a contributor can only reveal identifiers in their own local map; identifiers others created pass through unchanged (no risk of collision-corruption).

#### Mapping store

Path: `~/.config/apache-magpie/pii-mapping.json` — outside the project tree, per the framework's home-dir tool-credentials rule.

Format:

```text
{
  "version": 1,
  "entries": {
    "N-a3f9d2": {"type": "name", "value": "Jane Smith"},
    "E-b8c247": {"type": "email", "value": "jane.smith@example.com"}
  }
}
```

Properties:

- File mode `0o600`, atomic writes (`tempfile + os.replace`).
- Per-machine, never committed.
- Append-only in normal operation. Manual `rm` is supported but loses the reverse mapping; the agent has to re-fetch source data to rebuild on demand.

#### Skill wiring — the redact-after-fetch protocol

Every `<security-list>`-touching skill follows this canonical sequence:

1. **Resolve collaborators** — `gh api repos/<tracker>/collaborators --jq '.[].login'`. Same source-of-truth as the prompt-injection rule's "who is authorised to instruct the agent" lookup.
2. **Identify third-party PII candidates** in the body, signature lines, CVE credit fields, HackerOne/GHSA fields.
3. **Filter the candidate set**: drop the reporter's own values, drop every collaborator. What remains is the **should-be-redacted set** — third-party PII that is neither the reporter nor a collaborator.
4. **Call the redactor** with `--field <type>:<value>` for each remaining candidate.
5. **Use the redacted body for all subsequent processing.** The original un-redacted body is dropped from the agent's working set; if it is needed again, the skill re-fetches.

#### Reveal-before-send protocol

When a skill is about to emit a body that **(a)** carries a redacted third-party identifier AND **(b)** is destined for a surface that needs the real value (a draft reply to the reporter, a CVE credit line), `pii-reveal` runs once on the rendered text right before the send tool is called. Reveal does **not** run on internal status comments / sync messages where the redacted form is fine for the security team.

### 4.3 Mechanism 2 — approved-LLM gate

#### The default-approved registry

Four classes are pre-approved by the framework:

| Class | Rationale | Examples |
|---|---|---|
| **Claude Code itself** | The Claude Code instance running framework skills is treated as approved for the data it directly processes. See §9 for the limits of this default. | The agent invoking the skill. The checker matches the case-insensitive substring `claude code` in the bullet's raw text. |
| **`*.apache.org` endpoints** | Anything served from an ASF domain runs on infra under ASF governance — data residency, retention, access bounded by the ASF infra agreement. | Future ASF inference endpoint at e.g. `inference.apache.org`. |
| **Local-only inference** | Data never leaves the user's machine. | Ollama / vLLM / llama.cpp on `127.0.0.1`, `localhost`, `::1`. |
| **Air-gapped on-prem** | Same rationale as local, scaled to the contributor's organisation, on infra the adopter operationally controls and which has no path to a third-party LLM operator. | PMC-hosted inference appliance on a private VLAN. |

#### The opt-in tier

Every other LLM endpoint requires explicit declaration in `<project-config>/privacy-llm.md`, with three required fields per entry:

- the endpoint URL (or provider product name);
- the data-residency / retention contract that backs the choice (link to a contract clause, vendor doc, or BAA-equivalent);
- the security-team member who approved the addition (`Approved-by: <initials> <YYYY-MM-DD>`).

The framework intentionally does not ship a curated allow-list of third-party endpoints. The opt-in mechanism puts the choice — and the responsibility — on the adopting project's security team, where ASF policy expects it to live.

The gate-check **rejects placeholder text** in the `Approved-by` line — strings containing `<pmc-member-initials>`, `<initials>`, `<yyyy-mm-dd>`, or the literal `yyyy-mm-dd` are not accepted as a valid sign-off. An adopter that copies the template and forgets to fill in the approver gets a clear failure rather than a silent pass.

Setup recipes in [`docs/setup/privacy-llm.md`](https://github.com/apache/magpie/blob/main/docs/setup/privacy-llm.md) cover six concrete variants: Claude Code only, Local Ollama, Local vLLM, Apache-hosted endpoint, AWS Bedrock (opt-in), Direct Anthropic API (opt-in).

#### The pre-flight check

Skills run this check at Step 0 by shelling out to the `privacy-llm-check` console script (PR #51):

```text
uv run --project <framework>/tools/privacy-llm/checker privacy-llm-check \
  --reads-private-list                 # set when the skill may read <private-list>
```

The checker:

1. **Locates the config.** Precedence: `--config <path>` → `$PRIVACY_LLM_CONFIG` → `<cwd>/.apache-magpie/privacy-llm.md` → `<cwd>/.apache-magpie-overrides/privacy-llm.md`.
2. **Parses** the *Currently configured LLM stack* and *Approved third-party endpoints (opt-in)* sections. The parser is permissive about comments and whitespace but strict about the section-heading anchors.
3. **Applies the rules** for every active-stack entry:
   - Claude Code → ✓ default-approved
   - URL host ending in `.apache.org` → ✓ default-approved
   - URL host in `{localhost, 127.0.0.1, ::1}` → ✓ default-approved
   - Otherwise: match against the opt-in registry. A valid match requires a non-empty *Data-residency contract* sub-bullet AND a non-placeholder *Approved-by* sub-bullet.
4. **Returns an exit code:**
   - `0` — all entries approved.
   - `1` — one or more entries unapproved (or empty stack); stderr lists the offending entries plus a `Fix: edit <path>` pointer.
   - `2` — config file could not be located or parsed.

The check is deliberately conservative: any single unapproved entry stops the skill. Adding a new LLM hop is a deliberate act, not an emergent one.

**Defence-in-depth:** the gate-check is **also required** for `<security-list>`-only skills, even though their body classification permits Claude-Code-default LLMs by construction. Running the check at Step 0 ensures the adopter's config is in a sane state — no half-configured opt-in entries, no LLMs in the active stack the adopter forgot to approve — before any private content flows. The `--reads-private-list` flag controls only the printed banner; the validation logic is the same either way.

### 4.4 Mechanism 3 (defence-in-depth) — egress-allowlist gateway

The PII redactor and approved-LLM gate both operate at the application layer: they constrain what a skill deliberately sends to an LLM. Neither stops an *unintended* outbound flow — a buggy skill, a mis-wired tool, or a prompt-injection payload hidden in an inbound report that coaxes the agent into `curl`-ing private data to an attacker-controlled host. [`docs/setup/secure-agent-setup.md`](https://github.com/apache/magpie/blob/main/docs/setup/secure-agent-setup.md) flags exactly this: network egress via `Bash(curl *)` / `Bash(wget *)` bypasses the sandbox's own proxy.

The egress-allowlist gateway closes that gap at the network layer. It is a local `proxy.py` forward proxy (shipped as [`tools/egress-gateway/`](../../tools/egress-gateway/)) that enforces a **default-deny host allowlist** in its `before_upstream_connection` hook: any CONNECT / request to a host not on the allowlist is rejected with `403` before a socket is opened. Tools point `HTTPS_PROXY` / `HTTP_PROXY` at it; Python `urllib`-based tools (ponymail, whimsy, jira, …) honour that with no code change.

| Property | Value |
|---|---|
| Layer | Network egress (host-level), below the application-layer LLM controls |
| Policy | Default-deny; allowlist mirrors `sandbox.network.allowedDomains` (ASF infra, GitHub, Google APIs, PyPI), suffix-matched; loopback always allowed; adopter extends via `EGRESS_ALLOW_EXTRA` |
| Granularity | Host only — HTTPS is tunnelled via CONNECT, so no URL-path or payload inspection (no TLS interception) |
| Relationship | Defence-in-depth. Layered *under* mechanisms 1 + 2, never a replacement: the redactor still strips third-party PII, the gate still bounds which LLM may receive a body, and the gateway additionally bounds which host *any* tool may reach. |

The gateway runs **outside the sandbox** — it must bind a listener and make unrestricted outbound, which is precisely its job as the chokepoint. Sandboxed tools reach it over loopback, which requires `localhost` / `127.0.0.1` in `sandbox.network.allowedDomains` (loopback-only; this does not widen the internet egress surface — that becomes the gateway's responsibility). The gateway's allowlist and `sandbox.network.allowedDomains` encode the same egress policy at two layers and should be kept in sync.

This mechanism is **optional and provisional**: it ships as a tool with a documented contract and unit-tested allowlist policy, but it is not yet wired into a setup skill or the `privacy-llm-check` gate. See §10.6.

## 5. Data flow

```text
                 ┌─────────────────────┐
fetch (Gmail / ──┤  raw body + PII     │
PonyMail)        └──────────┬──────────┘
                            │  pii-redact (per-field, after collaborator filter)
                            ▼
                 ┌──────────────────────┐
                 │ body w/ identifiers  │ ◄─── what Claude / any
                 └──────────┬───────────┘      downstream LLM ever sees
                            │
                  …agent processing,
                  draft composition,
                  cross-skill handoff…
                            │
                            ▼
                 ┌──────────────────────┐
                 │ draft w/ identifiers │
                 └──────────┬───────────┘
                            │  pii-reveal (only at outbound boundary)
                            ▼
                 ┌──────────────────────┐
                 │ draft w/ real names  │ ──► sent to reporter
                 └──────────────────────┘
```

Three rules govern the lifecycle:

1. **Redact immediately after fetch.** The window between the `mcp__claude_ai_Gmail__get_thread` (or equivalent) tool call and the redact call is a single tool invocation wide.
2. **Operate on identifiers throughout.** All intermediate work (analysis, summarisation, draft composition, prior-art lookup, cross-skill handoff) runs against `<TYPE>-<hex>` text.
3. **Reveal only at the outbound boundary.** `pii-reveal` runs exactly once per draft, at the moment the rendered draft is handed to the send/draft-create tool. It does not run while the agent is *thinking about* the draft — only when the bytes are leaving the framework.

The gate-check (`privacy-llm-check`) sits **upstream** of this pipeline at Step 0 — before any fetch, before any redact, before the agent has touched private data at all. Its failure mode is "skill never starts"; its success mode is "skill starts and the redactor takes over downstream".

## 6. Implementation

`tools/privacy-llm/` ships **two stdlib-only `uv` Python sub-tools**, each with its own `pyproject.toml`, lock file, and test suite.

### 6.1 The redactor sub-tool — `tools/privacy-llm/redactor/`

Three console scripts:

| Script | Purpose |
|---|---|
| `pii-redact` | Replace declared PII values in stdin with identifiers; persist new mappings to the local file. |
| `pii-reveal` | Replace identifiers in stdin with stored real values from the local mapping. |
| `pii-list` | Print the current mapping for debugging (text or JSON). |

Three call sites in skill files:

```text
# Redact (immediately after fetch):
echo "$BODY" | uv run --project <framework>/tools/privacy-llm/redactor pii-redact \
  --field name:"Other Researcher" \
  --field email:"other@example.com" \
  --field handle:"otherresearcher-personal"

# Reveal (only at outbound boundary):
echo "$DRAFT" | uv run --project <framework>/tools/privacy-llm/redactor pii-reveal

# List (debugging only — output goes to user's terminal, never to LLM):
uv run --project <framework>/tools/privacy-llm/redactor pii-list
```

`<framework>` is the standard placeholder convention — substitutes to the snapshot path inside an adopter, or to `.` standalone. The redactor reads no config file: it just does what the caller passes via `--field`. Per-project knobs are applied by the calling skill (see §7).

The implementation is **stdlib-only** by design — `argparse`, `hashlib`, `json`, `pathlib`, `tempfile`, `os`. No third-party runtime dependencies. The dev group adds `pytest`, `ruff`, `mypy` for lint and test. Test count: **48 unit tests**, all passing.

### 6.2 The checker sub-tool — `tools/privacy-llm/checker/` (PR #51)

One console script:

| Script | Purpose |
|---|---|
| `privacy-llm-check` | Parse `<project-config>/privacy-llm.md`, verify every entry in the *Currently configured LLM stack* section is approved per the rules in [`models.md`](https://github.com/apache/magpie/blob/main/tools/privacy-llm/models.md). |

The internal structure is two modules:

- **`checker/config.py`** — parses the markdown config file into `LLMEntry` and `OptInEntry` dataclasses. Permissive about comments and whitespace; strict about the heading anchors `## Currently configured LLM stack` and `## Approved third-party endpoints (opt-in)` since those are the contract surfaces the gate-check relies on.
- **`checker/check.py`** — applies the default-approval rules (Claude Code substring match, `*.apache.org` host suffix, local-host set), falls back to the opt-in registry, and produces a `Verdict` per stack entry with a human-readable reason.

Skill invocation pattern:

```text
# Default lookup against <cwd>/.apache-magpie/privacy-llm.md or
# <cwd>/.apache-magpie-overrides/privacy-llm.md:
uv run --project <framework>/tools/privacy-llm/checker privacy-llm-check \
  --reads-private-list

# Sample success output (stdout):
# privacy-llm-check: every active-stack entry is approved (skill reads <private-list>)
# ✓ Claude Code (the agent running framework skills) — Claude Code itself (default-approved)
# ✓ Local Ollama at http://127.0.0.1:11434/ — local-only inference at 127.0.0.1 (default-approved)

# Sample failure (stderr, exit 1):
# privacy-llm-check: 1 of 2 active-stack entries are not approved.
# ✓ Claude Code (the agent running framework skills) — Claude Code itself (default-approved)
# ✗ AWS Bedrock at https://bedrock-runtime.eu-central-1.amazonaws.com — no default-approval rule matches and no opt-in entry was declared for this LLM. Add an entry under 'Approved third-party endpoints (opt-in)' with a Data-residency contract line and an Approved-by sign-off, or remove this LLM from the active stack.
#
# Fix: edit /repo/.apache-magpie/privacy-llm.md per tools/privacy-llm/models.md.
```

Implementation: stdlib-only (`argparse`, `dataclasses`, `re`, `urllib.parse`, `pathlib`). Test count: **33 unit tests**, all passing, including a fixture test that the shipped [`projects/_template/privacy-llm.md`](https://github.com/apache/magpie/blob/main/projects/_template/privacy-llm.md) parses + approves out of the box. Pre-commit hooks (ruff, ruff-format, mypy, pytest) wired into the framework's `prek` config in PR #51.

### 6.3 What never reaches any LLM

The framework treats these surfaces as off-limits to LLM context, even when an "approved" LLM is in the stack:

- The contents of `~/.config/apache-magpie/pii-mapping.json`. The file is read by `pii-redact` / `pii-reveal` only. Skills MUST NOT include the mapping in any LLM-bound prompt, summary, or status comment. For debugging, run `pii-list` in the user's terminal — that output goes to the user's screen, not to Claude's context.
- The `--field <type>:<value>` arguments themselves. Every value passed there is exactly what the redactor is replacing.
- Any draft text *before* `pii-reveal` runs, when the destination is a non-internal surface (e.g. a public PR comment) — the body would still carry identifiers, which leak no PII, but skills should not emit identifier-laden drafts to non-internal destinations by accident. The destination check in the approved-LLM gate is a separate safety net for this.

### 6.4 The egress gateway — `tools/egress-gateway/`

A `proxy.py`-based forward proxy whose only first-party code is the allowlist plugin (`egress_gateway.allowlist.EgressAllowlistPlugin`). The host-matching policy (`host_allowed`) is a pure function, unit-tested in isolation; the proxy.py integration is intentionally not exercised in CI (it needs to bind a port). Unlike the stdlib-only `privacy-llm` sub-tools, this one carries a third-party runtime dependency (`proxy.py`) — which is why it is a separate tool rather than a `privacy-llm` sub-tool. Contract: [`tools/egress-gateway/tool.md`](../../tools/egress-gateway/tool.md); how-to: [`tools/egress-gateway/README.md`](../../tools/egress-gateway/README.md).

## 7. Adopter configuration

Adopters declare their privacy-LLM posture in a single markdown file at `<project-config>/privacy-llm.md` (template at [`projects/_template/privacy-llm.md`](https://github.com/apache/magpie/blob/main/projects/_template/privacy-llm.md)). The file has four sections:

- **Currently configured LLM stack** — every LLM the adopter has wired into any skill, one per line.
- **Approved third-party endpoints (opt-in)** — entries beyond the default-approved set, each with the data-residency contract link and PMC `Approved-by` line. The checker rejects placeholder text (`<initials>`, `<YYYY-MM-DD>`, …).
- **Private mailing lists** — every PMC-private list the security team reads. The framework's `tools/ponymail/` reuses this list for its `private_lists` config knob, so the two stay in sync.
- **Redaction configuration** — three per-project knobs:

| Knob | Default | Purpose |
|---|---|---|
| `collaborator_source` | `<tracker>` from `<project-config>/project.md` | Override if collaborators are tracked in a different repo (parent-org roster, separate roster repo). |
| `collaborator_exemption` | `enabled` | Flip to `disabled` for a stricter posture: every non-reporter individual gets redacted, including collaborators. |
| `redaction_field_types` | all six | Disable individual types if a project has decided a different sensitivity tradeoff (rare). |

The redactor itself reads no config — knobs are applied by the calling skill at filter time, before `--field` arguments are constructed. The checker reads only the *Currently configured LLM stack*, *Approved third-party endpoints (opt-in)* sections — the other knobs are skill-side concerns. A skill that does not respect a knob is a framework bug.

## 8. Skill wiring summary

Every skill that touches `<security-list>` (or may escalate to `<private-list>`) carries a Step 0 *Privacy-LLM contract* bullet that calls `privacy-llm-check`, plus the redact-after-fetch and (where applicable) reveal-before-send steps.

| Skill | Reads | Drafts | Step 0 gate-check | Redact-after-fetch | Reveal-before-send |
|---|---|---|---|---|---|
| `security-issue-import` | `<security-list>` | reporter receipt-of-confirmation reply | ✓ | ✓ | ✓ |
| `security-issue-sync` | `<security-list>`, may escalate to `<private-list>` | reporter status updates | ✓ (`--reads-private-list`) | ✓ | ✓ |
| `security-issue-invalidate` | `<security-list>` | reporter invalidation reply | ✓ | ✓ | ✓ |
| `security-cve-allocate` | tracker + Vulnogram | (tracker already redacted) | ✓ | n/a (downstream of redaction) | n/a |
| `security-issue-import-from-md` | adopter-supplied markdown | n/a | ✓ | n/a | n/a |
| `security-issue-import-from-pr` | public PR | n/a | n/a (no `<security-list>` content) | n/a | n/a |
| `security-issue-fix` | tracker (already redacted) | n/a (PR is public; no PII) | n/a | n/a | n/a |
| `security-issue-deduplicate` | two trackers (already redacted) | n/a | n/a | n/a | n/a |

Only `security-issue-sync` passes `--reads-private-list` today (it may escalate threads to PMC-private foundation lists). The other wired skills run the checker without the flag — the validation logic is the same; the flag only affects the printed banner.

## 9. Trust boundaries and status

The default-approved registry reflects the framework maintainer's **working position** pending ASF Privacy VP/Legal VP ratification of an authoritative approved-LLM list for foundation private data. Specifically:

- The "Claude Code itself" default reflects the framework maintainer's current trust posture. If ASF Privacy VP/Legal VP subsequently rules that Anthropic-hosted endpoints require a data-processing agreement for foundation private data, the framework will narrow this default and bump the registry version. Adopters running Variant 1 (Claude Code only) at that point will need to re-evaluate.
- The `*.apache.org` blanket approval assumes infra-level governance. If a future ASF endpoint runs at `*.apache.org` but proxies to a third-party LLM, that endpoint may need re-classification.

When ASF Privacy VP/Legal VP do ratify a list, [`tools/privacy-llm/models.md`](https://github.com/apache/magpie/blob/main/tools/privacy-llm/models.md) becomes the *pointer* to that list rather than the list itself, and the default-approved entries get re-checked against it. Until then, that file is the framework's source-of-truth for adopters and the rationale-of-record for the choices it encodes.

PMC members and ASF Privacy VP/Legal VP reviewers who want to formalise the list should open an issue on [`apache/magpie`](https://github.com/apache/magpie) referencing this RFC.

## 10. Open questions and future work

### 10.1 Resolved in PR-3

Earlier drafts of this RFC listed "gate-call wiring" as deferred to PR-3. **PR #51 has now landed it**: the `tools/privacy-llm/checker/` sub-tool ships `privacy-llm-check` with the full default-approved logic, opt-in matching, placeholder rejection, and config auto-location. Every Gmail-touching skill calls it explicitly at Step 0. The full two-mechanism design is now live, end-to-end.

### 10.2 ASF Privacy VP/Legal VP ratification

The single largest remaining open question is the ASF-wide policy for AI-assisted handling of foundation private data. The framework's working position is documented and adopter-overridable, but a ratified list would let the framework bump from "provisional" to "stable" and remove the burden of per-project sign-off for default-approved entries.

Concrete asks for the ASF Privacy VP / Legal VP:

- **Confirm or narrow the "Claude Code itself" default.** Today the framework treats the running Claude Code instance as approved for the data it processes. A formal data-processing agreement between ASF and Anthropic for foundation private data would make this stable; absence of one might narrow the default to "Claude Code is approved only for `<security-list>` content, never for `<private-list>`".
- **Confirm the `*.apache.org` blanket.** The framework assumes any endpoint at an ASF domain runs under ASF infra governance. A formal articulation of what that means (including whether `*.apache.org` proxies to third parties are permitted) would let the framework codify the boundary precisely.
- **Publish a curated allow-list of opt-in endpoints**, if desired. The framework currently leaves the list open and shifts the responsibility to per-project security teams. A foundation-wide list would centralise the diligence.

### 10.3 MCP-layer hooks

If a future Claude Code MCP runtime gains per-tool transformation hooks, the redactor and gate-check call points can move from explicit-step-inside-the-skill into the hook without changing the contract. The current explicit-step design is forward-compatible with that migration: the `--field <type>:<value>` interface decouples *what to redact* (skill knowledge) from *how to redact* (helper logic), and the checker reads the same `<project-config>/privacy-llm.md` an MCP hook would.

### 10.4 Mapping-file lifecycle tools

The framework currently does not ship a cleanup tool for the mapping file. Manual `rm` is supported but loses the reverse mapping. Possible future additions: `pii-list --filter-stale` (entries that have not been revealed in N days), `pii-export` (cross-machine sync), `pii-rotate` (re-hash with a longer prefix). None blocking; all out of scope for the foundation.

### 10.5 Doc-cleanup follow-up

A small handful of references in [`docs/setup/privacy-llm.md`](https://github.com/apache/magpie/blob/main/docs/setup/privacy-llm.md) still describe `privacy-llm-check` as "PR-3" pending. Now that PR #51 has merged, those should be cleaned up to drop the "(PR-3)" phrasing — minor doc churn, no contract change. Filed as a follow-up for the next cleanup PR.

### 10.6 Egress-gateway wiring

The egress-allowlist gateway (§4.4, [`tools/egress-gateway/`](../../tools/egress-gateway/)) ships as a tool with a documented contract but is not yet wired into the setup flow. Possible follow-ups: a `setup-isolated-setup-*` step that launches / health-checks the gateway and persists `HTTPS_PROXY` into the adopter's per-machine settings; sourcing the gateway allowlist directly from `sandbox.network.allowedDomains` so the two cannot drift; and a `privacy-llm-check`-style assertion that the gateway is reachable when an adopter has opted into it. None blocking — the tool is usable standalone today.

## 11. References

- **Source-of-truth contracts**
  - [`tools/privacy-llm/tool.md`](https://github.com/apache/magpie/blob/main/tools/privacy-llm/tool.md) — overview
  - [`tools/privacy-llm/pii.md`](https://github.com/apache/magpie/blob/main/tools/privacy-llm/pii.md) — redaction contract
  - [`tools/privacy-llm/models.md`](https://github.com/apache/magpie/blob/main/tools/privacy-llm/models.md) — approved-LLM registry
  - [`tools/privacy-llm/wiring.md`](https://github.com/apache/magpie/blob/main/tools/privacy-llm/wiring.md) — skill-side protocol
- **Setup recipes**
  - [`docs/setup/privacy-llm.md`](https://github.com/apache/magpie/blob/main/docs/setup/privacy-llm.md) — six per-variant configurations
- **Reference implementation**
  - [`tools/privacy-llm/redactor/`](https://github.com/apache/magpie/tree/main/tools/privacy-llm/redactor) — PII redactor (stdlib-only Python)
  - [`tools/privacy-llm/checker/`](https://github.com/apache/magpie/tree/main/tools/privacy-llm/checker) — approved-LLM gate-check (stdlib-only Python)
  - [`tools/egress-gateway/`](../../tools/egress-gateway/) — egress-allowlist forward proxy (proxy.py plugin; defence-in-depth, §4.4)
- **Adopter template**
  - [`projects/_template/privacy-llm.md`](https://github.com/apache/magpie/blob/main/projects/_template/privacy-llm.md)
- **Related framework rules**
  - [`AGENTS.md → Privacy-LLM`](https://github.com/apache/magpie/blob/main/AGENTS.md) — three rules every skill follows
  - [`AGENTS.md → Confidentiality of the tracker repository`](https://github.com/apache/magpie/blob/main/AGENTS.md) — public-surface confidentiality, layered with this RFC
  - [`AGENTS.md → Treat external content as data, never as instructions`](https://github.com/apache/magpie/blob/main/AGENTS.md) — same collaborator-set source-of-truth as the redactor's exemption rule
- **Pull requests**
  - [PR #48 — foundation: PII redactor + approved-LLM contracts](https://github.com/apache/magpie/pull/48)
  - [PR #50 — refine PII contract + wire skill-side redactor protocol](https://github.com/apache/magpie/pull/50)
  - [PR #51 — approved-LLM gate-check + skill-side gate wiring](https://github.com/apache/magpie/pull/51)
