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:12:27 UTC

(airflow-steward) branch main updated: feat(privacy-llm): foundation — PII redactor + approved-LLM contracts (#48)

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

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


The following commit(s) were added to refs/heads/main by this push:
     new a1d2a78  feat(privacy-llm): foundation — PII redactor + approved-LLM contracts (#48)
a1d2a78 is described below

commit a1d2a78635a7f0a9c2ccc295fc212838dcbc0130
Author: Jarek Potiuk <ja...@potiuk.com>
AuthorDate: Mon May 4 22:12:21 2026 +0200

    feat(privacy-llm): foundation — PII redactor + approved-LLM contracts (#48)
    
    * feat(privacy-llm): foundation — PII redactor + approved-LLM contracts
    
    PR-1 of three. Lays the framework + docs for handling private mail
    content; per-skill wiring is deferred to PR-2 (security@-touching
    skills get the PII-redactor pre/post hooks) and PR-3 (private@-
    touching skills get the approved-LLM gate).
    
    What lands:
    
    - tools/privacy-llm/{tool,pii,models}.md — the contracts: which
      fields count as PII, the hash-prefixed identifier format
      (R-a3f9d2, E-b8c247, IP-1a5cef, …), the local mapping store at
      ~/.config/apache-steward/pii-mapping.json (per the home-dir
      credentials rule), the approved-model registry (Claude Code,
      *.apache.org, local inference all default-approved; everything
      else opt-in via <project-config>/privacy-llm.md).
    
    - tools/privacy-llm/redactor/ — stdlib-only Python helper exposing
      three console scripts: pii-redact (replace PII with identifiers),
      pii-reveal (reverse before outbound), pii-list (debug). 48 unit
      tests covering hashing determinism, idempotency, collision
      extension, atomic writes, mode 0600, malformed-file handling.
    
    - docs/setup/privacy-llm.md — copy-pasteable setup recipes for six
      variants: Claude-only (default), local Ollama, local vLLM,
      Apache-hosted, AWS Bedrock, direct Anthropic API. Marked
      provisional pending ASF Legal Affairs ratification of an
      authoritative approved-LLM list.
    
    - projects/_template/privacy-llm.md — adopter starting point
      pre-filled with the Claude-Code-only default.
    
    - AGENTS.md — new "Privacy-LLM" section (sibling to "Confidentiality
      of the tracker repository") codifying the three rules: reporter
      PII never enters any LLM in the clear; <private-list> content
      never reaches a non-approved LLM; adding a new LLM hop is a
      deliberate act, not an emergent one.
    
    - .pre-commit-config.yaml — new redactor-{ruff-check,ruff-format,
      mypy,pytest} hooks mirroring the oauth-draft pattern.
    
    The two privacy mechanisms are deliberately separate: PII redaction
    (always-on for security@) is mechanically distinct from the
    approved-model gate (pre-flight on private@-touching skills).
    Conflating them is a real foot-gun the framework guards against.
    
    Generated-by: Claude Code (Claude Opus 4.7)
    
    * fix(privacy-llm): regen doctoc TOCs + require prek install before any commit
    
    CI failure on PR #48 was doctoc — the new "Privacy-LLM" section in
    AGENTS.md and the new projects/_template/privacy-llm.md template
    both needed their TOCs regenerated. Local `prek run --all-files`
    confirms every hook passes.
    
    In the same PR, the AGENTS.md "Local setup" section is restructured
    so prek-install is now the first imperative — moved to the top of
    the section, marked "MUST be run before any other work in this
    repository — including the first commit on a fresh clone", and
    augmented with a one-line pre-flight check
    (`test -x .git/hooks/pre-commit || prek install`) that agents and
    human contributors run before preparing any commit. The old prek
    paragraph at the bottom of "Local setup" is removed (its content
    folded into the new top-of-section block).
    
    The reasoning the new text spells out: a missing local hook turns
    into a CI failure on push (the prek workflow re-runs the same hooks
    against the diff) — exactly the failure pattern this PR is
    recovering from. Making the install + verify steps explicit and
    mandatory closes that gap.
    
    Generated-by: Claude Code (Claude Opus 4.7)
---
 .pre-commit-config.yaml                            |  27 ++
 AGENTS.md                                          | 139 ++++++--
 docs/setup/privacy-llm.md                          | 380 +++++++++++++++++++++
 projects/_template/privacy-llm.md                  |  78 +++++
 tools/privacy-llm/models.md                        | 190 +++++++++++
 tools/privacy-llm/pii.md                           | 244 +++++++++++++
 tools/privacy-llm/redactor/.gitignore              |   7 +
 tools/privacy-llm/redactor/README.md               | 131 +++++++
 tools/privacy-llm/redactor/pyproject.toml          |  91 +++++
 .../privacy-llm/redactor/src/redactor/__init__.py  |  31 ++
 .../privacy-llm/redactor/src/redactor/list_cmd.py  |  88 +++++
 tools/privacy-llm/redactor/src/redactor/mapping.py | 233 +++++++++++++
 tools/privacy-llm/redactor/src/redactor/redact.py  | 143 ++++++++
 tools/privacy-llm/redactor/src/redactor/reveal.py  |  96 ++++++
 tools/privacy-llm/redactor/tests/__init__.py       |  16 +
 tools/privacy-llm/redactor/tests/test_list_cmd.py  |  83 +++++
 tools/privacy-llm/redactor/tests/test_mapping.py   | 228 +++++++++++++
 tools/privacy-llm/redactor/tests/test_redact.py    | 179 ++++++++++
 tools/privacy-llm/redactor/tests/test_reveal.py    | 163 +++++++++
 tools/privacy-llm/redactor/uv.lock                 | 271 +++++++++++++++
 tools/privacy-llm/tool.md                          | 139 ++++++++
 21 files changed, 2931 insertions(+), 26 deletions(-)

diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index c7737f4..c5399a0 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -153,3 +153,30 @@ repos:
         entry: uv run --directory tools/gmail/oauth-draft pytest
         files: ^tools/gmail/oauth-draft/(src|tests|pyproject\.toml)
         pass_filenames: false
+
+  - repo: local
+    hooks:
+      - id: redactor-ruff-check
+        name: ruff check (redactor)
+        language: system
+        entry: uv run --directory tools/privacy-llm/redactor ruff check
+        files: ^tools/privacy-llm/redactor/(src|tests|pyproject\.toml)
+        pass_filenames: false
+      - id: redactor-ruff-format
+        name: ruff format (redactor)
+        language: system
+        entry: uv run --directory tools/privacy-llm/redactor ruff format --check
+        files: ^tools/privacy-llm/redactor/(src|tests|pyproject\.toml)
+        pass_filenames: false
+      - id: redactor-mypy
+        name: mypy (redactor)
+        language: system
+        entry: uv run --directory tools/privacy-llm/redactor mypy
+        files: ^tools/privacy-llm/redactor/(src|tests|pyproject\.toml)
+        pass_filenames: false
+      - id: redactor-pytest
+        name: pytest (redactor)
+        language: system
+        entry: uv run --directory tools/privacy-llm/redactor pytest
+        files: ^tools/privacy-llm/redactor/(src|tests|pyproject\.toml)
+        pass_filenames: false
diff --git a/AGENTS.md b/AGENTS.md
index 5561ad9..a8b8659 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -14,6 +14,7 @@
     - [What public surfaces still must not contain](#what-public-surfaces-still-must-not-contain)
     - [Where the URLs are routinely OK to use](#where-the-urls-are-routinely-ok-to-use)
     - [Other ASF projects — never name or describe their vulnerabilities](#other-asf-projects--never-name-or-describe-their-vulnerabilities)
+  - [Privacy-LLM — what data goes through which model](#privacy-llm--what-data-goes-through-which-model)
   - [Assessing reports](#assessing-reports)
     - [Reporter-supplied CVSS scores are informational only — never propagate them](#reporter-supplied-cvss-scores-are-informational-only--never-propagate-them)
     - [CVE references must never point at non-public mailing-list threads](#cve-references-must-never-point-at-non-public-mailing-list-threads)
@@ -312,6 +313,51 @@ projects is a config change, not a code change.
 
 ## Local setup
 
+**`prek install` MUST be run before any other work in this
+repository — including the first commit on a fresh clone.** This
+repository uses [`prek`](https://github.com/j178/prek) (a fast,
+Rust-based drop-in replacement for `pre-commit`) to run pre-commit
+hooks that keep the documentation consistent — regenerating the
+`doctoc` tables of contents, stripping trailing whitespace,
+checking line endings, blocking accidentally committed secrets,
+and running the per-sub-tool `ruff` / `mypy` / `pytest` quality
+gates. The hook configuration lives in
+[`.pre-commit-config.yaml`](.pre-commit-config.yaml).
+
+```bash
+uv tool install prek   # or: pipx install prek
+prek install           # installs the git hook into .git/hooks/pre-commit
+```
+
+**Verify before every commit (agents and humans alike).** Before
+preparing any `git commit` — including the first commit on a
+fresh clone — confirm `.git/hooks/pre-commit` exists. If it does
+not, run `prek install` immediately; do **not** proceed to the
+commit step before the hook is in place. The CI re-runs the same
+hooks against every push (`prek` workflow at
+[`.github/workflows/`](.github/workflows/)) and rejects any commit
+whose contents do not match the hook's output, so a missing local
+hook silently turns into a CI failure on push. The pre-flight
+check is one line:
+
+```bash
+test -x .git/hooks/pre-commit || prek install
+```
+
+Run the hooks on demand:
+
+```bash
+prek run --all-files                 # run all hooks against every file
+prek run doctoc --all-files          # only regenerate TOCs
+prek run --from-ref airflow-s        # run against everything changed vs the base branch
+```
+
+If a hook modifies files (for example, `doctoc` regenerating a
+TOC), the commit is aborted; re-stage the modified files and
+commit again. **Do not bypass the hooks with `--no-verify`** —
+if a hook is failing, fix the underlying issue or update the
+hook configuration in the same PR.
+
 **Always run `git submodule update --init --recursive` after pulling
 the adopter tracker repository.** The framework lives at
 `<adopter-tracker>/.apache-steward/apache-steward/` as a git
@@ -360,32 +406,6 @@ copy-paste from upstream docs, generated to a temp scratch path),
 relocate it to a home-dir path and update the tool to read from
 there — never leave it in place "because it's already there".
 
-This repository uses [`prek`](https://github.com/j178/prek) (a fast, Rust-based drop-in
-replacement for `pre-commit`) to run pre-commit hooks that keep the documentation
-consistent — regenerating the `doctoc` tables of contents, stripping trailing whitespace,
-checking line endings, and blocking accidentally committed secrets. The hook configuration
-lives in [`.pre-commit-config.yaml`](.pre-commit-config.yaml).
-
-Install `prek` once and enable the hooks in your local clone before making any changes:
-
-```bash
-uv tool install prek   # or: pipx install prek
-prek install           # installs the git hook into .git/hooks/pre-commit
-```
-
-After that, every `git commit` in this repo will run the hooks automatically. You can also
-run them on demand:
-
-```bash
-prek run --all-files                 # run all hooks against every file
-prek run doctoc --all-files          # only regenerate TOCs
-prek run --from-ref airflow-s        # run against everything changed vs the base branch
-```
-
-If a hook modifies files (for example, `doctoc` regenerating a TOC), the commit is aborted;
-re-stage the modified files and commit again. **Do not bypass the hooks with `--no-verify`** —
-if a hook is failing, fix the underlying issue or update the hook configuration in the same PR.
-
 ## Commit and PR conventions
 
 - **MUST NOT use `Co-Authored-By:` with an AI agent as co-author.** Agents
@@ -624,6 +644,73 @@ doubt, leave it out — the cost of omitting useful context is
 low, the cost of leaking another project's private information is
 not.
 
+## Privacy-LLM — what data goes through which model
+
+The confidentiality rules above govern *human-visible* surfaces
+(public PRs, public issue comments, public mailing-list replies).
+A second, layered set of rules governs *machine-routed* surfaces
+— the LLM context the agent operates in, any LLM API call a
+skill makes, any delegated-summarisation hop a future skill might
+add. Both apply.
+
+The framework's privacy-LLM contract is enforced via
+[`tools/privacy-llm/`](tools/privacy-llm/tool.md) and configured
+per-adopter in `<project-config>/privacy-llm.md` (template at
+[`projects/_template/privacy-llm.md`](projects/_template/privacy-llm.md)).
+Setup recipes for the supported variants are in
+[`docs/setup/privacy-llm.md`](docs/setup/privacy-llm.md).
+
+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
+`~/.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).
+
+**`<private-list>` content never reaches a non-approved LLM.**
+PMC-private foundation list content (the project's
+`<private-list>` and any other PMC-private list the security
+team reads) is wholly private — body and PII alike. Skills that
+may read this content run a Step 0 pre-flight gate: if any LLM
+in the active stack is not in the approved-model registry, the
+skill stops. The default-approved set is Claude Code itself,
+anything at `*.apache.org`, local-only inference (Ollama / vLLM
+on `127.0.0.1`), and air-gapped on-prem endpoints. Everything
+else (AWS Bedrock, direct Anthropic API, Vertex, OpenAI, …) is
+opt-in, declared explicitly in `<project-config>/privacy-llm.md`
+with a data-residency contract link and a PMC-member approval
+line. The contract is in
+[`tools/privacy-llm/models.md`](tools/privacy-llm/models.md).
+
+**Adding a new LLM hop is a deliberate act, not an emergent one.**
+The pre-flight gate is conservative: any single unapproved entry
+in the active stack stops the skill. This makes it impossible
+for a skill to silently grow a second LLM dependency without the
+adopter's security team approving it in
+`<project-config>/privacy-llm.md`. When a skill needs to
+delegate to another LLM (a summarizer for long mail threads, a
+classifier, an outbound moderation step), the adopter wires the
+endpoint per the appropriate variant in
+[`docs/setup/privacy-llm.md`](docs/setup/privacy-llm.md) **before**
+the skill that uses it runs.
+
+**Status — provisional pending ASF Legal.** The default-approved
+list above reflects the framework maintainer's working position;
+ASF Legal Affairs has not yet ratified an authoritative
+approved-LLM list for foundation private data. When such a list
+lands, the registry will be updated to point at it as
+source-of-truth. Until then,
+[`tools/privacy-llm/models.md`](tools/privacy-llm/models.md) is
+the framework's source-of-truth and the rationale-of-record.
+
 ## Assessing reports
 
 ### Reporter-supplied CVSS scores are informational only — never propagate them
diff --git a/docs/setup/privacy-llm.md b/docs/setup/privacy-llm.md
new file mode 100644
index 0000000..9f7eb7c
--- /dev/null
+++ b/docs/setup/privacy-llm.md
@@ -0,0 +1,380 @@
+<!-- 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)*
+
+- [Privacy-LLM setup](#privacy-llm-setup)
+  - [The two mechanisms recap](#the-two-mechanisms-recap)
+  - [Claude Code trust boundary](#claude-code-trust-boundary)
+  - [Variant 1 — Claude Code only (default)](#variant-1--claude-code-only-default)
+  - [Variant 2 — Local inference (Ollama)](#variant-2--local-inference-ollama)
+  - [Variant 3 — Local inference (vLLM)](#variant-3--local-inference-vllm)
+  - [Variant 4 — Apache-hosted endpoint](#variant-4--apache-hosted-endpoint)
+  - [Variant 5 — AWS Bedrock](#variant-5--aws-bedrock)
+  - [Variant 6 — Direct Anthropic API (opt-in)](#variant-6--direct-anthropic-api-opt-in)
+  - [Verifying the setup](#verifying-the-setup)
+  - [Updating after a framework version bump](#updating-after-a-framework-version-bump)
+  - [Status — provisional pending ASF Legal](#status--provisional-pending-asf-legal)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/legal/release-policy.html -->
+
+# Privacy-LLM setup
+
+How to configure the framework's privacy-aware LLM routing for
+your adopting project. Pick a variant below; copy the matching
+`<project-config>/privacy-llm.md` block into your project; verify
+with `/setup-isolated-setup-verify` (or the privacy-llm-specific
+check once PR-3 lands the gate-call wiring).
+
+The contract behind these recipes lives in
+[`tools/privacy-llm/tool.md`](../../tools/privacy-llm/tool.md),
+[`tools/privacy-llm/pii.md`](../../tools/privacy-llm/pii.md), and
+[`tools/privacy-llm/models.md`](../../tools/privacy-llm/models.md).
+This file is **how-to**; those are the **what** and **why**.
+
+## The two mechanisms recap
+
+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).
+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
+   stack is in the approved-model registry.
+
+Picking a variant below configures the **gate** (the LLM stack).
+The redactor (mechanism 1) runs regardless and needs no
+per-variant config beyond the home-dir storage path.
+
+## Claude Code trust boundary
+
+The framework treats the Claude Code instance running the skills
+as **default-approved**: a working position the maintainer chose
+on 2026-05-04 in the absence of a ratified ASF Legal Affairs
+list. This means:
+
+- A pure Claude-only deployment (Variant 1) needs no per-LLM
+  approval workflow — the gate is satisfied by construction.
+- Adding **any** other LLM to the stack (a summarizer, a
+  delegated-analysis hop, an outbound classifier) requires
+  matching it against the registry per
+  [`tools/privacy-llm/models.md`](../../tools/privacy-llm/models.md).
+- If ASF Legal 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 using Variant 1 at that point will
+  need to re-evaluate.
+
+## Variant 1 — Claude Code only (default)
+
+The simplest variant. Claude Code is the only LLM in the stack;
+no external endpoints; the gate is auto-satisfied.
+
+**`<project-config>/privacy-llm.md`** content (copy verbatim,
+substitute `<private-list>` for your project's actual list):
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+
+## Approved third-party endpoints (opt-in)
+
+(none — Claude Code is the only LLM)
+
+## Private mailing lists for this project
+
+- private@<project>.apache.org
+```
+
+**Setup steps:**
+
+1. Place the file at `<project-config>/privacy-llm.md` in your
+   adopter repo (alongside `project.md`).
+2. Commit it. The file is project-config — it travels with the
+   repo, not per-machine.
+3. Run `/setup-isolated-setup-verify` to confirm the existing
+   secure-agent setup is in place — no new secure-setup steps
+   are needed for Variant 1.
+
+That is the entire variant. Every framework skill that consults
+`<project-config>/privacy-llm.md` will see "Claude-only" and pass
+the gate.
+
+## Variant 2 — Local inference (Ollama)
+
+Use when the project wants a second LLM in the stack — typically
+for delegated summarisation of long mail threads — without
+sending data to any external service.
+
+**Prerequisites:**
+
+- [Ollama](https://ollama.ai) installed locally (`brew install
+  ollama` on macOS; per-distribution package on Linux).
+- A model pulled (`ollama pull llama3.1:8b` or similar — the
+  framework does not prescribe which model).
+- Ollama bound to `127.0.0.1` only (the default; do not expose to
+  external interfaces).
+
+**`<project-config>/privacy-llm.md`** content:
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+- Local Ollama at http://127.0.0.1:11434/  (model: llama3.1:8b)
+
+## Approved third-party endpoints (opt-in)
+
+(none — local Ollama is local-only inference, default-approved)
+
+## Private mailing lists for this project
+
+- private@<project>.apache.org
+```
+
+**Setup steps:**
+
+1. Confirm Ollama is reachable: `curl
+   http://127.0.0.1:11434/api/tags` returns the model list.
+2. Confirm Ollama is **not** reachable from outside the host:
+   `curl http://<your-LAN-IP>:11434/api/tags` should fail.
+3. Place the file at `<project-config>/privacy-llm.md`. Commit.
+4. The framework helper detects `127.0.0.1` (and `localhost`,
+   `::1`) hostnames as default-approved local inference; no
+   third-party-endpoint declaration is needed.
+
+## Variant 3 — Local inference (vLLM)
+
+Same shape as Ollama but targeting vLLM for projects that need a
+larger model than Ollama hosts comfortably or need OpenAI-API
+compatibility for downstream tooling.
+
+**`<project-config>/privacy-llm.md`** content:
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+- Local vLLM at http://127.0.0.1:8000/v1/  (model: meta-llama/Llama-3.1-70B-Instruct)
+
+## Approved third-party endpoints (opt-in)
+
+(none — local vLLM is local-only inference, default-approved)
+
+## Private mailing lists for this project
+
+- private@<project>.apache.org
+```
+
+Same `127.0.0.1`-or-`localhost` test as Ollama applies.
+
+## Variant 4 — Apache-hosted endpoint
+
+Use when the ASF (or your project's PMC) hosts an inference
+endpoint at an `*.apache.org` domain. These are
+**default-approved** — anything served from an `*.apache.org`
+hostname runs on infra under ASF governance.
+
+**`<project-config>/privacy-llm.md`** content (substitute the
+actual endpoint):
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+- ASF inference at https://inference.apache.org/v1/  (model: llama3.1-asf)
+
+## Approved third-party endpoints (opt-in)
+
+(none — *.apache.org endpoints are default-approved)
+
+## Private mailing lists for this project
+
+- private@<project>.apache.org
+```
+
+**Setup steps:**
+
+1. Confirm the endpoint resolves under `*.apache.org`. The
+   framework helper greps the URL host suffix; `apache.org` is
+   the trigger.
+2. Confirm authentication if the endpoint requires it. ASF
+   endpoints typically authenticate via the user's ASF identity
+   (LDAP / OAuth); credentials live at
+   `~/.config/apache-steward/<endpoint>-token.json` or similar
+   — never in the project tree
+   (see [`AGENTS.md` — Local setup](../../AGENTS.md#local-setup)).
+3. Place the file at `<project-config>/privacy-llm.md`. Commit.
+
+## Variant 5 — AWS Bedrock
+
+**Opt-in.** AWS Bedrock with a region-bounded endpoint is a
+common choice for projects whose contributors are split across
+organisations and need a managed-inference fallback. The opt-in
+mechanism reflects that the data-residency contract is
+Bedrock-specific (region pinning, no-training, IAM-bounded
+access) and the adopter's security team is responsible for
+verifying it matches ASF expectations for foundation private
+data.
+
+**Prerequisites:**
+
+- An AWS account the adopter's security team controls.
+- Bedrock **enabled in a region you've verified for data
+  residency** (typically a region inside the EU or a region with
+  a Bedrock data-processing addendum that covers foundation
+  private data).
+- The model the project uses **enabled in that region** (Bedrock
+  requires per-region model enablement).
+- An IAM identity for the framework with
+  `bedrock:InvokeModel` (and nothing else) on the specific
+  model ARN.
+- The IAM credentials at `~/.aws/credentials` (default AWS SDK
+  path; never in the project tree).
+
+**`<project-config>/privacy-llm.md`** content:
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+- AWS Bedrock at https://bedrock-runtime.eu-central-1.amazonaws.com
+  (model: anthropic.claude-3-5-sonnet-20241022-v2:0)
+
+## Approved third-party endpoints (opt-in)
+
+- AWS Bedrock — eu-central-1
+  - Data-residency contract: AWS DPA + Bedrock no-training default
+    (https://aws.amazon.com/service-terms/, section 50.4 last
+    reviewed YYYY-MM-DD)
+  - IAM principal: arn:aws:iam::<account>:role/<project>-bedrock-readonly
+  - Approved-by: <PMC-member-initials> <YYYY-MM-DD>
+
+## Private mailing lists for this project
+
+- private@<project>.apache.org
+```
+
+**Setup steps:**
+
+1. Verify the region's data-residency contract matches your
+   project's expectations for foundation private data. Document
+   the link in the *Data-residency contract* line above.
+2. Verify Bedrock has *Model invocation logging* **disabled** (or
+   that any logging destination is inside the same compliance
+   boundary). The default is disabled.
+3. Provision the IAM role; place credentials at
+   `~/.aws/credentials`.
+4. Place the file at `<project-config>/privacy-llm.md` with the
+   *Approved-by* line filled in by a PMC member of the security
+   team. Commit.
+
+## Variant 6 — Direct Anthropic API (opt-in)
+
+**Opt-in.** Direct calls to the Anthropic API outside of Claude
+Code (e.g. for a delegated-summarisation hop) require a contract
+covering data-processing for ASF private data — typically a
+zero-data-retention agreement plus a no-training clause.
+
+**Prerequisites:**
+
+- An Anthropic account with a zero-data-retention agreement
+  applied to the API key.
+- The API key at `~/.config/apache-steward/anthropic-api.json`
+  or via `$ANTHROPIC_API_KEY` set from a home-dir-sourced
+  shell-rc — never in the project tree.
+
+**`<project-config>/privacy-llm.md`** content:
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+- Direct Anthropic API at https://api.anthropic.com/v1/
+  (model: claude-3-5-sonnet-20241022)
+
+## Approved third-party endpoints (opt-in)
+
+- Anthropic API direct
+  - Data-residency contract: ZDR + no-training agreement applied
+    to API key xxxxxx-…  (Anthropic console → Privacy → ZDR
+    confirmed YYYY-MM-DD)
+  - Approved-by: <PMC-member-initials> <YYYY-MM-DD>
+
+## Private mailing lists for this project
+
+- private@<project>.apache.org
+```
+
+The *Approved-by* line is required because Direct-Anthropic is
+opt-in. A `<project-config>/privacy-llm.md` that lists this
+endpoint without the *Approved-by* line will be flagged by the
+gate as incomplete.
+
+## Verifying the setup
+
+Once `<project-config>/privacy-llm.md` is in place:
+
+1. Run `/setup-isolated-setup-verify` to confirm the underlying
+   secure-agent setup is unchanged.
+2. (PR-3) Run the privacy-llm-specific check:
+
+   ```bash
+   uv run --project <framework>/tools/privacy-llm/redactor \
+     privacy-llm-check --reads-private-list
+   ```
+
+   Returns exit code 0 if the active stack is fully approved.
+3. Sanity-check the redactor end-to-end:
+
+   ```bash
+   echo "Hi I am Jane Smith and my email is jane@example.com" | \
+     uv run --project <framework>/tools/privacy-llm/redactor \
+     pii-redact \
+     --field reporter:"Jane Smith" \
+     --field email:"jane@example.com"
+   ```
+
+   Output should replace the two values with `R-…` and `E-…`
+   identifiers.
+4. List the resulting map:
+
+   ```bash
+   uv run --project <framework>/tools/privacy-llm/redactor pii-list
+   ```
+
+## Updating after a framework version bump
+
+The registry of default-approved entries can change between
+framework versions (e.g. ASF Legal ratifies a list, or a previously-
+default-approved class is narrowed). After running
+`/setup-steward upgrade`, re-run the verification checks above. If
+an entry that was previously default-approved is now opt-in, the
+gate will surface the gap and the adopter follows the recipe for
+the matching variant above.
+
+## Status — provisional pending ASF Legal
+
+This document and the registry it points at are **provisional**:
+they reflect the framework maintainer's current working position
+in the absence of a ratified ASF Legal Affairs / Privacy policy
+for AI-assisted handling of foundation private data. When such a
+policy lands, the registry will be updated to point at it as
+source-of-truth, and the variants above will be re-checked
+against it.
+
+If you are a PMC member or ASF Legal Affairs reviewer reading
+this and want to formalise the list: open an issue on
+[`apache/airflow-steward`](https://github.com/apache/airflow-steward)
+referencing this file. The framework will track ratification as
+a project memory and bump the registry version once the ratified
+list lands.
diff --git a/projects/_template/privacy-llm.md b/projects/_template/privacy-llm.md
new file mode 100644
index 0000000..7a9f03f
--- /dev/null
+++ b/projects/_template/privacy-llm.md
@@ -0,0 +1,78 @@
+<!-- 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)*
+
+- [Privacy-LLM configuration](#privacy-llm-configuration)
+  - [Currently configured LLM stack](#currently-configured-llm-stack)
+  - [Approved third-party endpoints (opt-in)](#approved-third-party-endpoints-opt-in)
+  - [Private mailing lists for this project](#private-mailing-lists-for-this-project)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/legal/release-policy.html -->
+
+<!-- Adopter template — copy this file into <project-config>/privacy-llm.md
+     and customise per the recipes in
+     ../../docs/setup/privacy-llm.md.
+
+     The default below (Variant 1 — Claude Code only) is the
+     starting state for a fresh adoption. Adopting projects that
+     wire in additional LLMs (Ollama, Bedrock, Anthropic direct,
+     etc.) replace the *Currently configured LLM stack* section
+     and populate *Approved third-party endpoints* per the recipe
+     for that variant. -->
+
+# Privacy-LLM configuration
+
+This file declares which LLM endpoints this project's framework
+skills are allowed to route private data through, and which
+mailing lists count as private.
+
+The contract behind these declarations lives in the framework at
+[`tools/privacy-llm/models.md`](../../tools/privacy-llm/models.md);
+the per-variant setup recipes are at
+[`docs/setup/privacy-llm.md`](../../docs/setup/privacy-llm.md).
+
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+
+<!-- Add additional LLMs here, one per line, with the endpoint URL
+     or provider name and the model name in parentheses. Example:
+
+       - Local Ollama at http://127.0.0.1:11434/  (model: llama3.1:8b)
+
+     For a Claude-Code-only deployment (Variant 1), leave this
+     section as-is.  -->
+
+## Approved third-party endpoints (opt-in)
+
+(none — Claude Code is the only LLM)
+
+<!-- Populate this section when adding any LLM that is NOT in
+     the framework's default-approved set
+     (Claude Code itself / *.apache.org / 127.0.0.1 or localhost
+     local inference). Each entry needs:
+
+       - Endpoint URL or provider product name
+       - Data-residency contract: <link or short identifier>
+       - Approved-by: <PMC-member-initials> <YYYY-MM-DD>
+
+     A `<project-config>/privacy-llm.md` that lists an opt-in
+     endpoint without the Approved-by line will be flagged as
+     incomplete by the privacy-llm-check helper. -->
+
+## Private mailing lists for this project
+
+- `<private-list>`
+
+<!-- List every PMC-private foundation list this project's
+     security team reads. The framework's privacy-llm gate
+     refuses to fetch from these lists unless the active LLM
+     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
+     ../../tools/privacy-llm/pii.md). -->
diff --git a/tools/privacy-llm/models.md b/tools/privacy-llm/models.md
new file mode 100644
index 0000000..1392d98
--- /dev/null
+++ b/tools/privacy-llm/models.md
@@ -0,0 +1,190 @@
+<!-- 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)*
+
+- [Approved-LLM registry](#approved-llm-registry)
+  - [The default-approved entries](#the-default-approved-entries)
+  - [The opt-in entries — adopter declares explicitly](#the-opt-in-entries--adopter-declares-explicitly)
+  - [The pre-flight check](#the-pre-flight-check)
+  - [Adopter config — `<project-config>/privacy-llm.md`](#adopter-config--project-configprivacy-llmmd)
+  - [How skills call the gate](#how-skills-call-the-gate)
+  - [Why this list is provisional](#why-this-list-is-provisional)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/legal/release-policy.html -->
+
+# Approved-LLM registry
+
+The registry answers a single question every skill that touches
+`<private-list>` content asks at Step 0: **"is the active LLM
+stack approved to receive this data?"** If yes, the skill
+proceeds. If no, it stops with a pointer at this doc and the
+adopter's `<project-config>/privacy-llm.md`.
+
+The registry has two tiers: **default-approved** entries that
+require no adopter action, and **opt-in** entries the adopter
+declares explicitly per the rationale in
+[*Why this list is provisional*](#why-this-list-is-provisional)
+below.
+
+## The default-approved entries
+
+These four classes are pre-approved by the framework. An adopter
+running with only these does **not** need to write
+`<project-config>/privacy-llm.md` (the gate auto-detects the
+default-approved Claude Code instance and passes).
+
+| Class | Rationale | Examples |
+|---|---|---|
+| **Claude Code itself** | The Claude-Code instance running framework skills is treated as an approved privacy model for the data it directly processes. See [`docs/setup/privacy-llm.md` — Claude Code trust boundary](../../docs/setup/privacy-llm.md#claude-code-trust-boundary) for the rationale and the limits of this default. | The agent invoking the skill |
+| **`*.apache.org`-hosted endpoints** | Anything served from an Apache Software Foundation domain runs on infra under ASF governance — data residency, retention, and access are bounded by the ASF infra agreement. | A future ASF-hosted inference endpoint at e.g. `inference.apache.org`; an in-tracker endpoint at `<project>.apache.org/llm/` |
+| **Local-only inference** | The data never leaves the user's machine. No external party (cloud LLM operator, network operator, log aggregator) can observe it. | Ollama serving a local model, vLLM on the user's workstation, llama.cpp embedded in a CLI helper |
+| **Air-gapped on-prem** | Same rationale as local inference, scaled to a contributor's organisation. The model server runs on infra the adopter operationally controls and which has no path to a third-party LLM operator. | A PMC-hosted inference appliance on a private VLAN |
+
+Detection lives in the helper's [registry.py](redactor/src/redactor/registry.py)
+(once PR-3 lands the gate-call wiring); for PR-1 the registry is
+the markdown contract here and the
+[`<project-config>/privacy-llm.md`](#adopter-config--project-configprivacy-llmmd)
+declaration shape.
+
+## The opt-in entries — adopter declares explicitly
+
+Every other LLM endpoint requires the adopter to declare it
+explicitly in `<project-config>/privacy-llm.md`, naming:
+
+- the endpoint URL (or provider product name);
+- the data-residency / retention contract that backs the choice
+  (a link to a contract clause, vendor doc, or BAA-equivalent);
+- the security-team member who approved the addition (initials +
+  date), so the audit trail is local and visible.
+
+The framework 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.
+
+Recipes for the most common opt-in cases (AWS Bedrock with a
+data-residency-bounded region, direct Anthropic API with a
+no-training agreement, Vertex AI with VPC-SC) are in
+[`docs/setup/privacy-llm.md`](../../docs/setup/privacy-llm.md).
+The recipes spell out the data-residency contract each one
+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:
+
+```text
+1. Read <project-config>/privacy-llm.md, if present.
+2. Build the active-LLM-stack set:
+     - Claude Code (always present — this is what's running)
+     - any model named in <project-config>/privacy-llm.md as
+       "currently configured"
+3. For every entry in the stack, decide approved? per:
+     - Claude Code → ✓ default-approved
+     - URL ending in .apache.org → ✓ default-approved
+     - Hostname ∈ {localhost, 127.0.0.1, ::1} → ✓ default-approved
+     - Listed under "approved third-party" with a complete
+       data-residency note → ✓ adopter-approved
+     - Anything else → ✗
+4. If every entry is approved, proceed.
+   If any entry is not approved, stop with:
+     "Skill <name> reads <private-list> content. The active LLM
+      stack contains <unapproved entry>, which is not in the
+      framework's default-approved list and is not declared in
+      <project-config>/privacy-llm.md. See
+      tools/privacy-llm/models.md and docs/setup/privacy-llm.md."
+```
+
+The check is deliberately conservative: any single unapproved
+entry in the stack stops the skill. The intent is to make adding
+a new LLM hop a *deliberate* act, not something a skill can
+silently grow into.
+
+## Adopter config — `<project-config>/privacy-llm.md`
+
+Adopters declare their privacy-LLM posture in a single markdown
+file at `<project-config>/privacy-llm.md`. The framework's
+[`projects/_template/privacy-llm.md`](../../projects/_template/privacy-llm.md)
+ships a starting point pre-filled with the Claude-Code default;
+adopters customise from there.
+
+The file has three sections:
+
+```markdown
+## Currently configured LLM stack
+
+- Claude Code (the agent running framework skills)
+<!-- list every additional LLM the adopter has wired into any
+     skill or tool here, one per line, with the endpoint URL or
+     provider name. -->
+
+## Approved third-party endpoints (opt-in)
+
+<!-- adopter populates this section per the recipes in
+     docs/setup/privacy-llm.md. Each entry includes:
+     - endpoint URL / provider name
+     - data-residency contract (link)
+     - approved-by: <initials> <YYYY-MM-DD>
+     For empty (Claude-Code-only) deployments this section
+     stays empty. -->
+
+## Private mailing lists for this project
+
+- `<private-list>`           # PMC private list
+- (any additional PMC-private foundation lists this project's
+   security team reads, e.g. cross-project security relay lists)
+```
+
+The "Private mailing lists" section is what
+[`tools/ponymail/`](../ponymail/) reads for the
+`tools.ponymail.private_lists` config knob; the privacy-llm tool
+re-uses the same source-of-truth so the two stay in sync.
+
+## How skills call the gate
+
+Skills call the gate via a small Python helper that ships with
+the redactor sub-tool (in PR-3 — the gate-call wiring is
+deferred from PR-1 to keep the foundation diff focused). The
+contract for that helper, defined here for forward-compatibility:
+
+```bash
+# Returns exit code 0 if the active stack is fully approved,
+# non-zero with a stderr explanation if not. Skills run this at
+# Step 0 and bail on non-zero.
+uv run --project <framework>/tools/privacy-llm/redactor privacy-llm-check \
+  --reads-private-list                 # set when the skill may read <private-list>
+```
+
+For `<security-list>`-only skills the gate-call is not required
+(the body classification permits Claude-Code-default LLMs by
+construction); the redactor (`pii-redact`) is required regardless.
+
+## Why this list is provisional
+
+There is no ratified ASF Legal Affairs / Privacy policy yet that
+enumerates approved LLM endpoints for handling foundation private
+data. The default-approved list above is the **working position**
+the framework adopts until such a policy lands. Specifically:
+
+- The "Claude Code itself" default reflects the framework
+  maintainer's current trust posture (per
+  [`docs/setup/privacy-llm.md` — Claude Code trust boundary](../../docs/setup/privacy-llm.md#claude-code-trust-boundary)).
+  If ASF Legal subsequently rules that Anthropic-hosted endpoints
+  require a data-processing agreement for foundation private
+  data, the framework will narrow the default and bump the
+  registry version.
+- 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 Legal does ratify a list, this file becomes the
+*pointer* to that list rather than the list itself, and the
+default-approved entries get re-checked against it. Until then,
+this file is the framework's source-of-truth for adopters and
+the rationale-of-record for the choices it encodes.
diff --git a/tools/privacy-llm/pii.md b/tools/privacy-llm/pii.md
new file mode 100644
index 0000000..6496fdf
--- /dev/null
+++ b/tools/privacy-llm/pii.md
@@ -0,0 +1,244 @@
+<!-- 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)*
+
+- [PII redaction contract](#pii-redaction-contract)
+  - [What counts as PII](#what-counts-as-pii)
+  - [The identifier format](#the-identifier-format)
+  - [The mapping store](#the-mapping-store)
+  - [The redact-then-reveal lifecycle](#the-redact-then-reveal-lifecycle)
+  - [How skills call the redactor](#how-skills-call-the-redactor)
+  - [Determinism, idempotency, collisions](#determinism-idempotency-collisions)
+  - [What never reaches an LLM](#what-never-reaches-an-llm)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/legal/release-policy.html -->
+
+# PII redaction contract
+
+This file is the source-of-truth for **what gets redacted, into
+what shape, and when**. Skills implement the lifecycle defined
+here; the [`redactor/`](redactor/) Python helper provides the
+reference implementation.
+
+## What counts as PII
+
+The redactor handles these field types. Each maps to a single
+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. |
+
+Things that are **not** PII and the redactor leaves alone:
+
+- 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
+  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.
+
+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.
+
+## The identifier format
+
+```text
+<TYPE>-<6-char-lowercase-hex>
+```
+
+- `<TYPE>` is one of the prefixes from the table above (`R`, `E`,
+  `P`, `IP`, `H`, `A`).
+- The 6-char hex is the first 24 bits of `sha256(<lowercased,
+  trimmed value>)`, lowercased, no separator.
+- On hash collision (two distinct PII values map to the same
+  6-char prefix — first detected at insert time), the redactor
+  extends the second value's hex to 8 chars, then 12, then 16,
+  until the prefix is unique. The mapping file records the
+  extension.
+
+Examples:
+
+| Input | Identifier |
+|---|---|
+| `Jane Smith` | `R-a3f9d2` *(illustrative — actual hash differs)* |
+| `jane.smith@example.com` | `E-b8c247` |
+| `+1-555-0100` | `P-7d4e91` |
+| `192.0.2.42` | `IP-1a5cef` |
+| `@janesmith-personal` | `H-9e3b04` |
+
+The 6-char default gives ~16M slots before collision pressure
+becomes meaningful — comfortably above the lifetime PII volume of
+any single ASF project's security tracker.
+
+## The mapping store
+
+Storage path: **`~/.config/apache-steward/pii-mapping.json`** —
+home-dir per the framework's tool-credentials rule (see
+[`AGENTS.md` — Local setup](../../AGENTS.md#local-setup)).
+
+Format:
+
+```json
+{
+  "version": 1,
+  "entries": {
+    "R-a3f9d2": {"type": "reporter", "value": "Jane Smith"},
+    "E-b8c247": {"type": "email",    "value": "jane.smith@example.com"},
+    "P-7d4e91": {"type": "phone",    "value": "+1-555-0100"}
+  }
+}
+```
+
+- The file is mode `600` and lives outside the project tree —
+  the same security posture as `~/.config/apache-steward/gmail-oauth.json`.
+- Writes are atomic (`tempfile + os.replace`) so a crash mid-write
+  cannot leave a half-baked file.
+- The mapping is **per-machine, never committed**. Each
+  developer's local map will diverge — that is fine, identifiers
+  are stable per-value via the deterministic hash, so two
+  developers redacting the same body produce the same identifier
+  text without sharing the file.
+- The map is append-only in normal operation. No cleanup tool
+  ships with the framework; manual `rm` is supported but loses
+  the reverse mapping (the agent has to re-fetch source data to
+  rebuild on demand).
+
+## The redact-then-reveal lifecycle
+
+```text
+                 ┌─────────────────────┐
+fetch (Gmail / ──┤  raw body + PII     │
+PonyMail)        └──────────┬──────────┘
+                            │  pii-redact (TYPE inference per field)
+                            ▼
+                 ┌─────────────────────┐
+                 │  body w/ identifiers│ ◄─── this is what
+                 └──────────┬──────────┘      Claude / any
+                            │                 downstream LLM 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.** Before any LLM-bound step
+   touches the content — including Claude's own context — the
+   skill calls `pii-redact`. The window between fetch and redact
+   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
+   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
+   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.
+
+## How skills call the redactor
+
+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 email:"jane.smith@example.com" \
+  --field phone:"+1-555-0100"
+
+# Reveal: replace identifiers with real values from the local map.
+echo "$DRAFT" | uv run --project <framework>/tools/privacy-llm/redactor pii-reveal
+```
+
+`pii-redact` takes one or more `--field <type>:<value>` arguments
+declaring what to redact (the skill knows which header / signature
+field carried which value). The redactor finds those exact
+substrings in the input and swaps them.
+
+`pii-reveal` reads the input, scans for `<TYPE>-<hex>` patterns
+that match entries in the mapping file, and substitutes them with
+the stored real values. Identifiers not in the map are left
+unchanged — the skill knows it didn't redact those values, so they
+must be either someone else's redaction (not reversible here) or
+incidental text.
+
+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
+#    E-b8c247  email     jane.smith@example.com
+#    …
+```
+
+Skill files reference the same invocation via the `<framework>`
+placeholder — see
+[`AGENTS.md` — placeholder convention](../../AGENTS.md#placeholder-convention-used-in-skill-files).
+
+## 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")`.
+  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.
+- **Collision-resistant** — at the 6-char default, two distinct
+  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
+  is unique against the current map. Extension is permanent for
+  that mapping entry — once `R-a3f9d2ab` is in the file, that
+  value keeps the longer form forever.
+
+## What never reaches an LLM
+
+- The contents of `~/.config/apache-steward/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. If you need to debug what mapped to what, 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, if a skill
+  ever emits its tool-call arguments into a status comment. The
+  arguments are PII by construction — every value passed there is
+  exactly what the redactor is replacing.
+- Any draft text *before* `pii-reveal` runs, if the test path
+  short-circuits and skips reveal — the draft body would still
+  carry identifiers, which leak no PII, but skills should not
+  emit identifier-laden drafts to non-internal destinations
+  (e.g. a public PR comment) by accident. The
+  [`models.md`](models.md) gate is a separate safety net for
+  this — its destination check ensures non-approved destinations
+  see neither raw PII nor stale identifiers.
diff --git a/tools/privacy-llm/redactor/.gitignore b/tools/privacy-llm/redactor/.gitignore
new file mode 100644
index 0000000..8cae406
--- /dev/null
+++ b/tools/privacy-llm/redactor/.gitignore
@@ -0,0 +1,7 @@
+__pycache__/
+*.py[cod]
+.venv/
+.pytest_cache/
+.ruff_cache/
+.mypy_cache/
+.coverage
diff --git a/tools/privacy-llm/redactor/README.md b/tools/privacy-llm/redactor/README.md
new file mode 100644
index 0000000..4938c06
--- /dev/null
+++ b/tools/privacy-llm/redactor/README.md
@@ -0,0 +1,131 @@
+<!-- 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)*
+
+- [redactor](#redactor)
+  - [Run](#run)
+  - [Mapping file](#mapping-file)
+  - [Test](#test)
+  - [Lint / type-check](#lint--type-check)
+  - [Referenced by](#referenced-by)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- Licensed to the Apache Software Foundation (ASF) under one
+     or more contributor license agreements.  See the NOTICE file
+     distributed with this work for additional information
+     regarding copyright ownership.  The ASF licenses this file
+     to you under the Apache License, Version 2.0 (the
+     "License"); you may not use this file except in compliance
+     with the License.  You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+     Unless required by applicable law or agreed to in writing,
+     software distributed under the License is distributed on an
+     "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+     KIND, either express or implied.  See the License for the
+     specific language governing permissions and limitations
+     under the License. -->
+
+# redactor
+
+Stdlib-only Python project implementing the PII redactor for
+the apache-steward privacy-llm tool. Three console scripts:
+
+| Console script | Purpose |
+|---|---|
+| `pii-redact` | Replace declared PII values in stdin with hash-prefixed 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). |
+
+The behavioural contract — which fields count as PII, the
+identifier format, the redact-then-reveal lifecycle — lives in
+[`../pii.md`](../pii.md). This README covers local-setup and
+day-to-day invocation.
+
+## Run
+
+From the framework's root (this repository when running standalone;
+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" | \
+  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>
+
+# Reveal: identifiers in stdin → real values from the local map.
+echo "Reply to R-<hex>" | \
+  uv run --project tools/privacy-llm/redactor pii-reveal
+# →  Reply to Jane Smith
+
+# List the current map (text or JSON):
+uv run --project tools/privacy-llm/redactor pii-list
+uv run --project tools/privacy-llm/redactor pii-list --format json
+```
+
+Skill files reference the same invocation via the `<framework>`
+placeholder so the path resolves in either context:
+
+```bash
+uv run --project <framework>/tools/privacy-llm/redactor pii-redact ...
+```
+
+`<framework>` substitutes to the snapshot path inside an adopting
+project (typically `.apache-steward/apache-steward/`) and to `.`
+when running standalone — see the placeholder convention in
+[`AGENTS.md`](../../../AGENTS.md#placeholder-convention-used-in-skill-files).
+
+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. |
+
+## Mapping file
+
+The mapping is stored at:
+
+```text
+~/.config/apache-steward/pii-mapping.json     (default)
+$PII_MAPPING_PATH                             (env override)
+--mapping-path <path>                         (per-call override)
+```
+
+The file is written with mode `0o600`, atomically (`tempfile +
+os.replace`). It is per-machine, never committed. The
+identifier-from-value derivation is deterministic, so two
+developers redacting the same value get the same identifier
+without sharing the file.
+
+## Test
+
+```bash
+# Run the test suite:
+uv run --project tools/privacy-llm/redactor --group dev pytest
+
+# With coverage (if you add coverage.py to the dev group locally):
+uv run --project tools/privacy-llm/redactor --group dev pytest --cov=redactor
+```
+
+## Lint / type-check
+
+```bash
+uv run --project tools/privacy-llm/redactor --group dev ruff check src tests
+uv run --project tools/privacy-llm/redactor --group dev ruff format --check src tests
+uv run --project tools/privacy-llm/redactor --group dev mypy
+```
+
+## Referenced by
+
+- [`tools/privacy-llm/tool.md`](../tool.md) — tool overview.
+- [`tools/privacy-llm/pii.md`](../pii.md) — PII redaction contract.
+- [`docs/setup/privacy-llm.md`](../../../docs/setup/privacy-llm.md) — setup recipes for adopters.
diff --git a/tools/privacy-llm/redactor/pyproject.toml b/tools/privacy-llm/redactor/pyproject.toml
new file mode 100644
index 0000000..306273e
--- /dev/null
+++ b/tools/privacy-llm/redactor/pyproject.toml
@@ -0,0 +1,91 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+[build-system]
+requires = ["hatchling"]
+build-backend = "hatchling.build"
+
+[project]
+name = "redactor"
+version = "0.1.0"
+description = "PII redactor for the apache-steward privacy-llm tool — replace reporter PII with hash-prefixed identifiers and reverse on outbound."
+readme = "README.md"
+requires-python = ">=3.11"
+license = { text = "Apache-2.0" }
+# stdlib-only — hashlib + json + argparse + pathlib are all that's
+# needed. Keeping the dep set empty makes the redactor cheap to
+# invoke from skills via `uv run --project ...`.
+dependencies = []
+
+[project.scripts]
+pii-redact = "redactor.redact:main"
+pii-reveal = "redactor.reveal:main"
+pii-list = "redactor.list_cmd:main"
+
+[dependency-groups]
+dev = [
+  "mypy>=1.11",
+  "pytest>=8.0",
+  "ruff>=0.6",
+]
+
+[tool.hatch.build.targets.wheel]
+packages = ["src/redactor"]
+
+[tool.ruff]
+line-length = 110
+target-version = "py311"
+src = ["src", "tests"]
+
+[tool.ruff.lint]
+select = [
+  "E",     # pycodestyle errors
+  "W",     # pycodestyle warnings
+  "F",     # pyflakes
+  "I",     # isort
+  "B",     # flake8-bugbear
+  "UP",    # pyupgrade
+  "SIM",   # flake8-simplify
+  "C4",    # flake8-comprehensions
+  "RUF",   # ruff-specific
+]
+ignore = [
+  "E501",  # line-too-long — the 110-char limit above is already generous
+]
+
+[tool.ruff.lint.per-file-ignores]
+"tests/**" = ["B", "SIM"]
+
+[tool.mypy]
+python_version = "3.11"
+files = ["src", "tests"]
+warn_unused_ignores = true
+warn_redundant_casts = true
+warn_unreachable = true
+check_untyped_defs = true
+no_implicit_optional = true
+disallow_untyped_defs = true
+disallow_incomplete_defs = true
+
+[[tool.mypy.overrides]]
+module = "tests.*"
+disallow_untyped_defs = false
+disallow_incomplete_defs = false
+
+[tool.pytest.ini_options]
+minversion = "8.0"
+addopts = "-ra -q"
+testpaths = ["tests"]
diff --git a/tools/privacy-llm/redactor/src/redactor/__init__.py b/tools/privacy-llm/redactor/src/redactor/__init__.py
new file mode 100644
index 0000000..718e98f
--- /dev/null
+++ b/tools/privacy-llm/redactor/src/redactor/__init__.py
@@ -0,0 +1,31 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""PII redactor for the apache-steward privacy-llm tool.
+
+Three console scripts:
+
+- ``pii-redact`` (:mod:`redactor.redact`) — replace declared PII
+  values in stdin with hash-prefixed identifiers; updates the
+  local mapping file in place.
+- ``pii-reveal`` (:mod:`redactor.reveal`) — replace identifiers
+  in stdin with their stored real values from the local mapping.
+- ``pii-list`` (:mod:`redactor.list_cmd`) — print the current
+  mapping for debugging.
+
+The contract these implement is documented in
+``tools/privacy-llm/pii.md``.
+"""
diff --git a/tools/privacy-llm/redactor/src/redactor/list_cmd.py b/tools/privacy-llm/redactor/src/redactor/list_cmd.py
new file mode 100644
index 0000000..d48ae6c
--- /dev/null
+++ b/tools/privacy-llm/redactor/src/redactor/list_cmd.py
@@ -0,0 +1,88 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""``pii-list`` — print the local PII mapping for debugging.
+
+Output goes to stdout (the user's terminal), not into any
+LLM-bound surface. The command exists so the user can answer
+"what mapped to what" without inspecting the JSON file by hand.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import sys
+
+from redactor.mapping import (
+    MAPPING_VERSION,
+    load_mapping,
+    locate_mapping_path,
+)
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="pii-list",
+        description="Print the current PII mapping for debugging.",
+    )
+    parser.add_argument(
+        "--mapping-path",
+        default=None,
+        help=(
+            "Override the mapping file path. "
+            "Default: $PII_MAPPING_PATH or ~/.config/apache-steward/pii-mapping.json."
+        ),
+    )
+    parser.add_argument(
+        "--format",
+        choices=("text", "json"),
+        default="text",
+        help="Output format. Default: text.",
+    )
+    args = parser.parse_args(argv)
+
+    mapping_path = locate_mapping_path(args.mapping_path)
+    try:
+        mapping = load_mapping(mapping_path)
+    except ValueError as e:
+        print(str(e), file=sys.stderr)
+        return 2
+
+    if args.format == "json":
+        payload = {
+            "version": MAPPING_VERSION,
+            "mapping_path": str(mapping_path),
+            "entries": {
+                ident: {"type": entry.type, "value": entry.value} for ident, entry in sorted(mapping.items())
+            },
+        }
+        json.dump(payload, sys.stdout, indent=2, sort_keys=False)
+        sys.stdout.write("\n")
+        return 0
+
+    if not mapping:
+        print(f"(empty — {mapping_path} has no entries)")
+        return 0
+
+    # Text format: ident<TAB>type<TAB>value, sorted by identifier.
+    for ident, entry in sorted(mapping.items()):
+        print(f"{ident}\t{entry.type}\t{entry.value}")
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tools/privacy-llm/redactor/src/redactor/mapping.py b/tools/privacy-llm/redactor/src/redactor/mapping.py
new file mode 100644
index 0000000..2151f80
--- /dev/null
+++ b/tools/privacy-llm/redactor/src/redactor/mapping.py
@@ -0,0 +1,233 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Local PII mapping store + identifier generation.
+
+The mapping file at ``~/.config/apache-steward/pii-mapping.json``
+records ``identifier → {type, value}`` so :mod:`redactor.reveal`
+can reverse the substitution made by :mod:`redactor.redact`.
+Identifiers are deterministic (first 24 bits of
+``sha256(value.lower().strip())`` hex-encoded, prefixed by a
+type code) so the mapping file is convenience storage, not the
+source of truth for the identifier itself.
+
+The contract is documented in ``tools/privacy-llm/pii.md``.
+"""
+
+from __future__ import annotations
+
+import dataclasses
+import hashlib
+import json
+import os
+import pathlib
+import tempfile
+from collections.abc import Mapping
+
+MAPPING_VERSION = 1
+
+DEFAULT_MAPPING_DIR = pathlib.Path.home() / ".config" / "apache-steward"
+DEFAULT_MAPPING_PATH = DEFAULT_MAPPING_DIR / "pii-mapping.json"
+ENV_MAPPING_PATH = "PII_MAPPING_PATH"
+
+# Type code → friendly type name in the stored entries. The codes
+# are the prefix the identifier carries; the friendly names are
+# what `pii-list` prints.
+TYPE_CODES: dict[str, str] = {
+    "R": "reporter",
+    "E": "email",
+    "P": "phone",
+    "IP": "ip",
+    "H": "handle",
+    "A": "address",
+}
+# Reverse map: friendly type → code.
+TYPE_NAMES: dict[str, str] = {v: k for k, v in TYPE_CODES.items()}
+
+# Default identifier hex length (24 bits = 6 hex chars). On
+# collision we extend in 8-bit increments.
+DEFAULT_HEX_LEN = 6
+COLLISION_EXTEND_BY = 2  # 2 hex chars = 8 bits
+
+
+@dataclasses.dataclass(frozen=True)
+class Entry:
+    """One mapping row: identifier → real PII value, with type."""
+
+    identifier: str
+    type: str  # friendly type name (e.g. "reporter", "email")
+    value: str
+
+
+def locate_mapping_path(explicit: str | None = None) -> pathlib.Path:
+    """Resolve the mapping path: ``--mapping-path`` → env → default."""
+    if explicit:
+        return pathlib.Path(explicit).expanduser()
+    if env_path := os.environ.get(ENV_MAPPING_PATH):
+        return pathlib.Path(env_path).expanduser()
+    return DEFAULT_MAPPING_PATH
+
+
+def load_mapping(path: pathlib.Path) -> dict[str, Entry]:
+    """Load the mapping file. Returns ``{}`` if the file is missing.
+
+    Raises ``ValueError`` if the file exists but is malformed or
+    is at an unsupported version — the caller surfaces this to the
+    user rather than silently overwriting.
+    """
+    if not path.exists():
+        return {}
+    raw = json.loads(path.read_text())
+    if not isinstance(raw, dict):
+        raise ValueError(f"{path}: expected a JSON object at the top level")
+    version = raw.get("version")
+    if version != MAPPING_VERSION:
+        raise ValueError(
+            f"{path}: mapping version {version!r}; this tool understands version {MAPPING_VERSION}. "
+            f"Upgrade the redactor or migrate the mapping file."
+        )
+    entries_raw = raw.get("entries", {})
+    if not isinstance(entries_raw, dict):
+        raise ValueError(f"{path}: 'entries' must be an object")
+    out: dict[str, Entry] = {}
+    for ident, body in entries_raw.items():
+        if not isinstance(body, dict):
+            raise ValueError(f"{path}: entry {ident!r} must be an object")
+        try:
+            out[ident] = Entry(
+                identifier=ident,
+                type=str(body["type"]),
+                value=str(body["value"]),
+            )
+        except KeyError as e:
+            raise ValueError(f"{path}: entry {ident!r} missing field {e.args[0]!r}") from e
+    return out
+
+
+def save_mapping_atomic(path: pathlib.Path, mapping: Mapping[str, Entry]) -> None:
+    """Write the mapping file atomically with mode 0o600.
+
+    Uses ``tempfile.NamedTemporaryFile`` + ``os.replace`` so a
+    crash mid-write cannot leave a half-written file in place.
+    """
+    path.parent.mkdir(parents=True, exist_ok=True)
+    payload = {
+        "version": MAPPING_VERSION,
+        "entries": {
+            ident: {"type": entry.type, "value": entry.value} for ident, entry in sorted(mapping.items())
+        },
+    }
+    serialised = json.dumps(payload, indent=2, sort_keys=False) + "\n"
+    # delete=False so we control the rename; mode 0o600 set after
+    # write to handle the cross-platform case (NamedTemporaryFile
+    # creates the file with default umask).
+    with tempfile.NamedTemporaryFile(
+        mode="w",
+        encoding="utf-8",
+        dir=path.parent,
+        prefix=path.name,
+        suffix=".tmp",
+        delete=False,
+    ) as tmp:
+        tmp.write(serialised)
+        tmp_path = pathlib.Path(tmp.name)
+    os.chmod(tmp_path, 0o600)
+    os.replace(tmp_path, path)
+
+
+def normalise_value(value: str) -> str:
+    """Canonical form for hashing — lowercase + trim outer whitespace.
+
+    Two values that differ only in case or trailing whitespace
+    map to the same identifier. We deliberately do **not** strip
+    internal whitespace (so ``Jane  Smith`` and ``Jane Smith``
+    are distinct) — internal whitespace can be intentional in
+    rare names and we do not want to coerce it.
+    """
+    return value.strip().lower()
+
+
+def hash_value(value: str) -> str:
+    """Hex-encoded SHA-256 of the value's normalised form."""
+    return hashlib.sha256(normalise_value(value).encode("utf-8")).hexdigest()
+
+
+def generate_identifier(
+    type_code: str,
+    value: str,
+    existing: Mapping[str, Entry],
+) -> tuple[str, bool]:
+    """Compute the identifier for ``(type_code, value)``.
+
+    Returns ``(identifier, is_collision_extended)``. The default
+    is ``<TYPE_CODE>-<6 hex chars>``. If a different value already
+    occupies that identifier, the hex length is extended by
+    :data:`COLLISION_EXTEND_BY` (default 2) until the identifier
+    is unique against ``existing``.
+
+    Idempotency: if ``value`` is already mapped under the same
+    type, the existing identifier is returned unchanged regardless
+    of length.
+    """
+    if type_code not in TYPE_CODES:
+        raise ValueError(f"unknown type code {type_code!r}; valid codes: {sorted(TYPE_CODES)}")
+
+    friendly = TYPE_CODES[type_code]
+    canonical = normalise_value(value)
+
+    # Idempotency check — if this value is already mapped under
+    # this type, return the existing identifier so we never grow
+    # a second entry for the same value.
+    for ident, entry in existing.items():
+        if entry.type == friendly and normalise_value(entry.value) == canonical:
+            return ident, False
+
+    full_hex = hash_value(value)
+    hex_len = DEFAULT_HEX_LEN
+    extended = False
+    while hex_len <= len(full_hex):
+        candidate = f"{type_code}-{full_hex[:hex_len]}"
+        if candidate not in existing:
+            return candidate, extended
+        # Identifier is taken — by a different value (idempotency
+        # already returned above). Extend the hex length.
+        hex_len += COLLISION_EXTEND_BY
+        extended = True
+    raise RuntimeError(
+        f"unable to find a unique identifier for value (type={type_code!r}) "
+        f"after extending to full {len(full_hex)}-char hash; the mapping file is "
+        f"saturated. This is effectively impossible — investigate the mapping file."
+    )
+
+
+def upsert(
+    mapping: dict[str, Entry],
+    type_code: str,
+    value: str,
+) -> Entry:
+    """Insert or fetch the mapping entry for ``(type_code, value)``.
+
+    Mutates ``mapping`` in place when a new entry is created.
+    Returns the resulting :class:`Entry` either way.
+    """
+    identifier, _ = generate_identifier(type_code, value, mapping)
+    if identifier not in mapping:
+        mapping[identifier] = Entry(
+            identifier=identifier,
+            type=TYPE_CODES[type_code],
+            value=value,
+        )
+    return mapping[identifier]
diff --git a/tools/privacy-llm/redactor/src/redactor/redact.py b/tools/privacy-llm/redactor/src/redactor/redact.py
new file mode 100644
index 0000000..b8b1596
--- /dev/null
+++ b/tools/privacy-llm/redactor/src/redactor/redact.py
@@ -0,0 +1,143 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""``pii-redact`` — replace declared PII values in stdin with identifiers.
+
+Reads stdin (UTF-8), substitutes every occurrence of each declared
+``--field <type>:<value>`` with the matching hash-prefixed
+identifier, writes the result to stdout, persists any new
+mapping entries to the local mapping file.
+
+Skills shell out to this command immediately after fetching
+content from a private source. See ``tools/privacy-llm/pii.md``
+for the lifecycle.
+"""
+
+from __future__ import annotations
+
+import argparse
+import sys
+
+from redactor.mapping import (
+    TYPE_NAMES,
+    Entry,
+    load_mapping,
+    locate_mapping_path,
+    save_mapping_atomic,
+    upsert,
+)
+
+
+def parse_field(spec: str) -> tuple[str, str]:
+    """Parse a ``<type>:<value>`` spec into ``(type_code, value)``.
+
+    The ``<type>`` half accepts either a friendly type name
+    (``reporter``, ``email``, …) or the type code (``R``, ``E``,
+    …). Returns the canonical type code.
+    """
+    if ":" not in spec:
+        raise SystemExit(f"--field value must be of the form <type>:<value>; got {spec!r}")
+    type_part, value = spec.split(":", 1)
+    type_part = type_part.strip()
+    if not value:
+        raise SystemExit(f"--field {type_part!r}: value is empty")
+    # 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"}:
+        return type_part.upper(), value
+    raise SystemExit(
+        f"unknown field type {type_part!r}. "
+        f"Use one of: {', '.join(sorted(TYPE_NAMES))} (friendly) "
+        f"or {', '.join(sorted(TYPE_NAMES.values()))} (code)."
+    )
+
+
+def apply_redactions(text: str, fields: list[tuple[str, str, Entry]]) -> str:
+    """Substitute every declared value with its identifier.
+
+    Substitutes longer values first so that a value which is a
+    substring of another (e.g. reporter ``Jane`` inside email
+    ``jane@x.com``) does not break the longer match.
+    """
+    # Sort by raw value length descending — exact-string match,
+    # so longer values get substituted first and cannot be
+    # disrupted by a shorter substring substitution.
+    fields_sorted = sorted(fields, key=lambda triple: len(triple[1]), reverse=True)
+    for _type_code, value, entry in fields_sorted:
+        if not value:
+            continue
+        text = text.replace(value, entry.identifier)
+    return text
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="pii-redact",
+        description="Replace declared PII values in stdin with hash-prefixed identifiers.",
+    )
+    parser.add_argument(
+        "--field",
+        action="append",
+        default=[],
+        metavar="<type>:<value>",
+        help=(
+            "PII to redact, declared as type:value. "
+            "Repeat for each field. Type is one of: "
+            "reporter, email, phone, ip, handle, address (or codes R, E, P, IP, H, A)."
+        ),
+    )
+    parser.add_argument(
+        "--mapping-path",
+        default=None,
+        help=(
+            "Override the mapping file path. "
+            "Default: $PII_MAPPING_PATH or ~/.config/apache-steward/pii-mapping.json."
+        ),
+    )
+    args = parser.parse_args(argv)
+
+    if not args.field:
+        # Nothing declared — pass stdin to stdout unchanged. This
+        # is a legitimate case (skill calling redact on a body
+        # that turned out to have no fields to redact for this
+        # message).
+        sys.stdout.write(sys.stdin.read())
+        return 0
+
+    mapping_path = locate_mapping_path(args.mapping_path)
+    try:
+        mapping = load_mapping(mapping_path)
+    except ValueError as e:
+        print(str(e), file=sys.stderr)
+        return 2
+
+    declared: list[tuple[str, str, Entry]] = []
+    for spec in args.field:
+        type_code, value = parse_field(spec)
+        entry = upsert(mapping, type_code, value)
+        declared.append((type_code, value, entry))
+
+    text = sys.stdin.read()
+    redacted = apply_redactions(text, declared)
+    sys.stdout.write(redacted)
+
+    save_mapping_atomic(mapping_path, mapping)
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tools/privacy-llm/redactor/src/redactor/reveal.py b/tools/privacy-llm/redactor/src/redactor/reveal.py
new file mode 100644
index 0000000..f012b4d
--- /dev/null
+++ b/tools/privacy-llm/redactor/src/redactor/reveal.py
@@ -0,0 +1,96 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""``pii-reveal`` — substitute identifiers in stdin with stored real values.
+
+Reads stdin (UTF-8), scans for ``<TYPE>-<hex>`` identifiers, and
+replaces any that appear in the local mapping with their stored
+real values. Identifiers not in the mapping are left untouched —
+the redactor that produced them is on a different machine, or
+the mapping file was deleted, and we have no way to reverse them.
+
+Skills shell out to this command exactly once per outbound draft,
+at the moment the rendered draft text is handed to the send /
+draft-create tool. See ``tools/privacy-llm/pii.md`` for the
+lifecycle.
+"""
+
+from __future__ import annotations
+
+import argparse
+import re
+import sys
+
+from redactor.mapping import (
+    TYPE_CODES,
+    Entry,
+    load_mapping,
+    locate_mapping_path,
+)
+
+# An identifier is one of the known type codes followed by a `-`
+# and 6+ lowercase hex chars. The codes are listed explicitly to
+# avoid matching arbitrary uppercase-prefixed tokens that happen
+# to look identifier-shaped (the framework's prose contains
+# things like ``HTTP-200`` that would match a generic
+# ``[A-Z]+-[0-9a-f]+`` pattern).
+_IDENTIFIER_PATTERN = re.compile(
+    r"\b(" + "|".join(sorted(TYPE_CODES, key=len, reverse=True)) + r")-([0-9a-f]{6,})\b"
+)
+
+
+def reveal(text: str, mapping: dict[str, Entry]) -> str:
+    """Replace each known identifier in ``text`` with its stored value."""
+
+    def _sub(match: re.Match[str]) -> str:
+        ident = match.group(0)
+        entry = mapping.get(ident)
+        if entry is None:
+            return ident
+        return entry.value
+
+    return _IDENTIFIER_PATTERN.sub(_sub, text)
+
+
+def main(argv: list[str] | None = None) -> int:
+    parser = argparse.ArgumentParser(
+        prog="pii-reveal",
+        description="Replace identifiers in stdin with stored real PII values.",
+    )
+    parser.add_argument(
+        "--mapping-path",
+        default=None,
+        help=(
+            "Override the mapping file path. "
+            "Default: $PII_MAPPING_PATH or ~/.config/apache-steward/pii-mapping.json."
+        ),
+    )
+    args = parser.parse_args(argv)
+
+    mapping_path = locate_mapping_path(args.mapping_path)
+    try:
+        mapping = load_mapping(mapping_path)
+    except ValueError as e:
+        print(str(e), file=sys.stderr)
+        return 2
+
+    text = sys.stdin.read()
+    sys.stdout.write(reveal(text, mapping))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())
diff --git a/tools/privacy-llm/redactor/tests/__init__.py b/tools/privacy-llm/redactor/tests/__init__.py
new file mode 100644
index 0000000..13a8339
--- /dev/null
+++ b/tools/privacy-llm/redactor/tests/__init__.py
@@ -0,0 +1,16 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
diff --git a/tools/privacy-llm/redactor/tests/test_list_cmd.py b/tools/privacy-llm/redactor/tests/test_list_cmd.py
new file mode 100644
index 0000000..d9ce29f
--- /dev/null
+++ b/tools/privacy-llm/redactor/tests/test_list_cmd.py
@@ -0,0 +1,83 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for ``redactor.list_cmd`` — the ``pii-list`` CLI."""
+
+from __future__ import annotations
+
+import io
+import json
+import pathlib
+
+from redactor import list_cmd
+from redactor.mapping import Entry, save_mapping_atomic
+
+
+def _seed(path: pathlib.Path) -> None:
+    save_mapping_atomic(
+        path,
+        {
+            "R-abcdef": Entry(identifier="R-abcdef", type="reporter", value="Jane Smith"),
+            "E-fedcba": Entry(identifier="E-fedcba", type="email", value="jane@example.com"),
+        },
+    )
+
+
+def _run(monkeypatch, argv: list[str]) -> tuple[str, int]:
+    stdout = io.StringIO()
+    monkeypatch.setattr("sys.stdout", stdout)
+    rc = list_cmd.main(argv)
+    return stdout.getvalue(), rc
+
+
+def test_list_text_format(tmp_path: pathlib.Path, monkeypatch):
+    _seed(tmp_path / "pii.json")
+    out, rc = _run(monkeypatch, ["--mapping-path", str(tmp_path / "pii.json")])
+    assert rc == 0
+    # Sorted by identifier; tab-separated columns.
+    lines = out.strip().splitlines()
+    assert len(lines) == 2
+    assert lines[0].startswith("E-fedcba\t")
+    assert "Jane Smith" in lines[1]
+
+
+def test_list_json_format(tmp_path: pathlib.Path, monkeypatch):
+    _seed(tmp_path / "pii.json")
+    out, rc = _run(
+        monkeypatch,
+        ["--mapping-path", str(tmp_path / "pii.json"), "--format", "json"],
+    )
+    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"
+
+
+def test_list_empty_text(tmp_path: pathlib.Path, monkeypatch):
+    out, rc = _run(monkeypatch, ["--mapping-path", str(tmp_path / "missing.json")])
+    assert rc == 0
+    assert "empty" in out.lower()
+
+
+def test_list_empty_json(tmp_path: pathlib.Path, monkeypatch):
+    out, rc = _run(
+        monkeypatch,
+        ["--mapping-path", str(tmp_path / "missing.json"), "--format", "json"],
+    )
+    assert rc == 0
+    payload = json.loads(out)
+    assert payload["entries"] == {}
diff --git a/tools/privacy-llm/redactor/tests/test_mapping.py b/tools/privacy-llm/redactor/tests/test_mapping.py
new file mode 100644
index 0000000..01b8ae8
--- /dev/null
+++ b/tools/privacy-llm/redactor/tests/test_mapping.py
@@ -0,0 +1,228 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for ``redactor.mapping`` — hashing, persistence, collisions."""
+
+from __future__ import annotations
+
+import json
+import pathlib
+
+import pytest
+
+from redactor.mapping import (
+    DEFAULT_HEX_LEN,
+    MAPPING_VERSION,
+    Entry,
+    generate_identifier,
+    hash_value,
+    load_mapping,
+    locate_mapping_path,
+    normalise_value,
+    save_mapping_atomic,
+    upsert,
+)
+
+# -- normalisation --------------------------------------------------------
+
+
+def test_normalise_lowercases_and_trims():
+    assert normalise_value("  Jane Smith  ") == "jane smith"
+
+
+def test_normalise_keeps_internal_whitespace():
+    # Two-space "Jane  Smith" stays distinct from "Jane Smith"
+    # because internal whitespace can be intentional.
+    assert normalise_value("Jane  Smith") == "jane  smith"
+
+
+# -- deterministic identifiers -------------------------------------------
+
+
+def test_hash_value_is_deterministic_across_case_and_whitespace():
+    assert hash_value("Jane Smith") == hash_value("  jane smith  ")
+
+
+def test_generate_identifier_default_length():
+    ident, extended = generate_identifier("R", "Jane Smith", existing={})
+    prefix, hex_part = ident.split("-", 1)
+    assert prefix == "R"
+    assert len(hex_part) == DEFAULT_HEX_LEN
+    assert all(c in "0123456789abcdef" for c in hex_part)
+    assert extended is False
+
+
+def test_generate_identifier_idempotent_for_same_value():
+    existing: dict[str, Entry] = {}
+    ident_a, _ = generate_identifier("R", "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)
+    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)
+    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={})
+    # 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",
+            value="some other reporter",
+        ),
+    }
+    new_ident, extended = generate_identifier("R", "Jane Smith", pre_seeded)
+    assert extended is True
+    assert new_ident != natural_ident
+    assert new_ident.startswith("R-")
+    # Extended hex is longer than the default.
+    _prefix, hex_part = new_ident.split("-", 1)
+    assert len(hex_part) > DEFAULT_HEX_LEN
+
+
+def test_generate_identifier_rejects_unknown_type():
+    with pytest.raises(ValueError, match="unknown type code"):
+        generate_identifier("Z", "anything", existing={})
+
+
+# -- upsert ---------------------------------------------------------------
+
+
+def test_upsert_creates_then_returns_existing():
+    mapping: dict[str, Entry] = {}
+    a = upsert(mapping, "R", "Jane Smith")
+    b = upsert(mapping, "R", "Jane Smith")
+    assert a is b or a == b
+    assert len(mapping) == 1
+    assert a.value == "Jane Smith"
+    assert a.type == "reporter"
+
+
+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")
+    e = upsert(mapping, "E", "foo")
+    assert r.identifier != e.identifier
+    assert r.identifier.startswith("R-")
+    assert e.identifier.startswith("E-")
+
+
+# -- file persistence ----------------------------------------------------
+
+
+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, "E", "jane@example.com")
+
+    save_mapping_atomic(path, mapping)
+    loaded = load_mapping(path)
+    assert loaded == mapping
+
+
+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")
+    save_mapping_atomic(path, mapping)
+    assert path.exists()
+
+
+def test_save_writes_mode_0o600(tmp_path: pathlib.Path):
+    path = tmp_path / "pii.json"
+    mapping: dict[str, Entry] = {}
+    upsert(mapping, "R", "Jane Smith")
+    save_mapping_atomic(path, mapping)
+    mode = path.stat().st_mode & 0o777
+    assert mode == 0o600
+
+
+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")
+    save_mapping_atomic(path, mapping)
+    raw = json.loads(path.read_text())
+    assert raw["version"] == MAPPING_VERSION
+    assert "entries" in raw
+
+
+def test_load_missing_file_returns_empty(tmp_path: pathlib.Path):
+    assert load_mapping(tmp_path / "does-not-exist.json") == {}
+
+
+def test_load_rejects_wrong_version(tmp_path: pathlib.Path):
+    path = tmp_path / "pii.json"
+    path.write_text(json.dumps({"version": 999, "entries": {}}))
+    with pytest.raises(ValueError, match="version"):
+        load_mapping(path)
+
+
+def test_load_rejects_top_level_array(tmp_path: pathlib.Path):
+    path = tmp_path / "pii.json"
+    path.write_text(json.dumps([]))
+    with pytest.raises(ValueError, match="JSON object"):
+        load_mapping(path)
+
+
+def test_load_rejects_malformed_entry(tmp_path: pathlib.Path):
+    path = tmp_path / "pii.json"
+    path.write_text(
+        json.dumps(
+            {
+                "version": MAPPING_VERSION,
+                "entries": {"R-abcdef": {"type": "reporter"}},  # missing value
+            }
+        )
+    )
+    with pytest.raises(ValueError, match="missing field 'value'"):
+        load_mapping(path)
+
+
+# -- locate_mapping_path -------------------------------------------------
+
+
+def test_locate_explicit_wins(tmp_path: pathlib.Path, monkeypatch):
+    monkeypatch.setenv("PII_MAPPING_PATH", "/should-not-be-used")
+    explicit = str(tmp_path / "explicit.json")
+    assert locate_mapping_path(explicit) == pathlib.Path(explicit)
+
+
+def test_locate_env_used_when_no_explicit(tmp_path: pathlib.Path, monkeypatch):
+    env_path = str(tmp_path / "from-env.json")
+    monkeypatch.setenv("PII_MAPPING_PATH", env_path)
+    assert locate_mapping_path(None) == pathlib.Path(env_path)
+
+
+def test_locate_default_when_no_explicit_or_env(monkeypatch):
+    monkeypatch.delenv("PII_MAPPING_PATH", raising=False)
+    result = locate_mapping_path(None)
+    assert result.parts[-2:] == ("apache-steward", "pii-mapping.json")
diff --git a/tools/privacy-llm/redactor/tests/test_redact.py b/tools/privacy-llm/redactor/tests/test_redact.py
new file mode 100644
index 0000000..b70b093
--- /dev/null
+++ b/tools/privacy-llm/redactor/tests/test_redact.py
@@ -0,0 +1,179 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for ``redactor.redact`` — the ``pii-redact`` CLI."""
+
+from __future__ import annotations
+
+import io
+import pathlib
+from collections.abc import Iterator
+
+import pytest
+
+from redactor import redact
+from redactor.mapping import load_mapping
+
+
+@pytest.fixture()
+def mapping_path(tmp_path: pathlib.Path, monkeypatch) -> Iterator[pathlib.Path]:
+    """Pin the mapping file to a tmp path for the duration of the test."""
+    path = tmp_path / "pii-mapping.json"
+    monkeypatch.setenv("PII_MAPPING_PATH", str(path))
+    yield path
+
+
+def _run(monkeypatch, stdin_text: str, argv: list[str]) -> tuple[str, int]:
+    """Invoke ``redact.main`` with ``stdin_text`` and capture stdout."""
+    stdin = io.StringIO(stdin_text)
+    stdout = io.StringIO()
+    monkeypatch.setattr("sys.stdin", stdin)
+    monkeypatch.setattr("sys.stdout", stdout)
+    rc = redact.main(argv)
+    return stdout.getvalue(), rc
+
+
+# -- field parsing -------------------------------------------------------
+
+
+def test_parse_field_friendly_name():
+    assert redact.parse_field("reporter:Jane Smith") == ("R", "Jane Smith")
+
+
+def test_parse_field_code():
+    assert redact.parse_field("R:Jane Smith") == ("R", "Jane Smith")
+
+
+def test_parse_field_value_can_contain_colon():
+    """An email address containing ``:`` must round-trip through the parser."""
+    assert redact.parse_field("email:foo:bar@example.com") == ("E", "foo:bar@example.com")
+
+
+def test_parse_field_rejects_missing_colon():
+    with pytest.raises(SystemExit, match="must be of the form"):
+        redact.parse_field("just-a-value")
+
+
+def test_parse_field_rejects_unknown_type():
+    with pytest.raises(SystemExit, match="unknown field type"):
+        redact.parse_field("nope:value")
+
+
+def test_parse_field_rejects_empty_value():
+    with pytest.raises(SystemExit, match="value is empty"):
+        redact.parse_field("reporter:")
+
+
+# -- end-to-end redaction ------------------------------------------------
+
+
+def test_redact_replaces_values_with_identifiers(mapping_path, monkeypatch):
+    body = "Hi I am Jane Smith and you can email me at jane@example.com."
+    out, rc = _run(
+        monkeypatch,
+        body,
+        ["--field", "reporter: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 "E-" in out
+
+
+def test_redact_persists_mapping(mapping_path, monkeypatch):
+    _, rc = _run(
+        monkeypatch,
+        "Jane Smith",
+        ["--field", "reporter: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.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"])
+    assert out_a == out_b
+    assert len(load_mapping(mapping_path)) == 1
+
+
+def test_redact_no_fields_passes_input_through(mapping_path, monkeypatch):
+    """No ``--field`` declared → stdin → stdout unchanged."""
+    out, rc = _run(monkeypatch, "untouched text", [])
+    assert rc == 0
+    assert out == "untouched text"
+    assert load_mapping(mapping_path) == {}
+
+
+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"])
+    assert "Jane Smith" not in out
+    # Exactly three identifiers in the output.
+    mapping = load_mapping(mapping_path)
+    [entry] = mapping.values()
+    assert out.count(entry.identifier) == 3
+
+
+def test_redact_substring_safety(mapping_path, monkeypatch):
+    """A short value that is a substring of a longer value should not break the longer match.
+
+    Reporter ``Jane`` is a substring of email ``jane@example.com``.
+    With longest-first substitution the email is replaced first,
+    so the email's identifier never gets re-redacted by the
+    reporter pass.
+    """
+    body = "Jane wrote from jane@example.com."
+    out, _ = _run(
+        monkeypatch,
+        body,
+        ["--field", "reporter:Jane", "--field", "email:jane@example.com"],
+    )
+    # The email's identifier should be intact (not contain "R-").
+    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")
+    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"])
+    assert out == "no PII here"
+    # The value was still recorded in the mapping (skill's
+    # responsibility to declare; we trust the declaration).
+    mapping = load_mapping(mapping_path)
+    assert len(mapping) == 1
+
+
+def test_redact_returns_2_on_malformed_mapping_file(mapping_path, monkeypatch):
+    mapping_path.parent.mkdir(parents=True, exist_ok=True)
+    mapping_path.write_text("not json")
+    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"])
+    assert rc == 2
diff --git a/tools/privacy-llm/redactor/tests/test_reveal.py b/tools/privacy-llm/redactor/tests/test_reveal.py
new file mode 100644
index 0000000..d102b31
--- /dev/null
+++ b/tools/privacy-llm/redactor/tests/test_reveal.py
@@ -0,0 +1,163 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""Tests for ``redactor.reveal`` — the ``pii-reveal`` CLI."""
+
+from __future__ import annotations
+
+import io
+import pathlib
+from collections.abc import Iterator
+
+import pytest
+
+from redactor import redact, reveal
+from redactor.mapping import load_mapping
+
+
+@pytest.fixture()
+def seeded_mapping(tmp_path: pathlib.Path, monkeypatch) -> Iterator[pathlib.Path]:
+    """Run a redact pass to seed a mapping, then yield its path."""
+    path = tmp_path / "pii-mapping.json"
+    monkeypatch.setenv("PII_MAPPING_PATH", str(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"])
+    yield path
+
+
+def _reveal(monkeypatch, stdin_text: str) -> tuple[str, int]:
+    stdin = io.StringIO(stdin_text)
+    stdout = io.StringIO()
+    monkeypatch.setattr("sys.stdin", stdin)
+    monkeypatch.setattr("sys.stdout", stdout)
+    rc = reveal.main([])
+    return stdout.getvalue(), rc
+
+
+# -- core round-trip ----------------------------------------------------
+
+
+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")
+    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)
+    assert rc == 0
+    assert "Jane Smith" in out
+    assert "jane@example.com" in out
+    assert reporter.identifier not in out
+    assert email.identifier not in out
+
+
+def test_reveal_leaves_unknown_identifiers_intact(seeded_mapping, monkeypatch):
+    """An identifier shape that is NOT in the mapping is passed through.
+
+    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."
+    out, _ = _reveal(monkeypatch, text)
+    assert out == text
+
+
+def test_reveal_does_not_match_arbitrary_uppercase_dash_hex(seeded_mapping, monkeypatch):
+    """``HTTP-200`` should pass through unchanged — wrong prefix, wrong hex shape."""
+    text = "Got HTTP-200 OK back."
+    out, _ = _reveal(monkeypatch, text)
+    assert out == text
+
+
+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")
+    text = f"({reporter.identifier})"
+    out, _ = _reveal(monkeypatch, text)
+    assert out == "(Jane Smith)"
+
+
+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."
+    out, rc = _reveal(monkeypatch, text)
+    assert rc == 0
+    assert out == text
+
+
+def test_reveal_round_trip_with_redact(tmp_path: pathlib.Path, monkeypatch):
+    """``pii-redact`` then ``pii-reveal`` should be a no-op on the text."""
+    monkeypatch.setenv("PII_MAPPING_PATH", str(tmp_path / "rt.json"))
+    body = "Hi I am Jane Smith and my email is jane@example.com."
+    # Redact pass.
+    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"])
+    # Reveal pass.
+    monkeypatch.setattr("sys.stdin", io.StringIO(redacted_buf.getvalue()))
+    revealed_buf = io.StringIO()
+    monkeypatch.setattr("sys.stdout", revealed_buf)
+    reveal.main([])
+    assert revealed_buf.getvalue() == body
+
+
+# -- internals ----------------------------------------------------------
+
+
+def test_reveal_function_directly():
+    """Smoke-test the pure function without going through stdin."""
+    from redactor.mapping import Entry
+
+    mapping = {
+        "R-abcdef": Entry(identifier="R-abcdef", type="reporter", value="Jane Smith"),
+    }
+    assert reveal.reveal("Hello R-abcdef.", mapping) == "Hello Jane Smith."
+
+
+def test_reveal_returns_2_on_malformed_mapping_file(tmp_path: pathlib.Path, monkeypatch):
+    path = tmp_path / "pii.json"
+    path.write_text("not json at all")
+    monkeypatch.setenv("PII_MAPPING_PATH", str(path))
+    monkeypatch.setattr("sys.stdin", io.StringIO("ignored"))
+    monkeypatch.setattr("sys.stdout", io.StringIO())
+    monkeypatch.setattr("sys.stderr", io.StringIO())
+    rc = reveal.main([])
+    assert rc == 2
+
+
+@pytest.mark.parametrize("type_code", ["IP"])
+def test_reveal_handles_two_char_prefix(tmp_path: pathlib.Path, monkeypatch, type_code: str):
+    """Two-character prefixes (e.g. ``IP-``) round-trip correctly."""
+    monkeypatch.setenv("PII_MAPPING_PATH", str(tmp_path / "ip.json"))
+    body = "I tested from 192.0.2.42."
+    # Redact.
+    monkeypatch.setattr("sys.stdin", io.StringIO(body))
+    monkeypatch.setattr("sys.stdout", io.StringIO())
+    redact.main(["--field", "ip:192.0.2.42"])
+    # Read mapping to grab identifier.
+    mapping = load_mapping(tmp_path / "ip.json")
+    [entry] = mapping.values()
+    assert entry.identifier.startswith("IP-")
+    # Reveal.
+    monkeypatch.setattr("sys.stdin", io.StringIO(f"see {entry.identifier} please"))
+    out_buf = io.StringIO()
+    monkeypatch.setattr("sys.stdout", out_buf)
+    reveal.main([])
+    assert out_buf.getvalue() == "see 192.0.2.42 please"
diff --git a/tools/privacy-llm/redactor/uv.lock b/tools/privacy-llm/redactor/uv.lock
new file mode 100644
index 0000000..f386ffd
--- /dev/null
+++ b/tools/privacy-llm/redactor/uv.lock
@@ -0,0 +1,271 @@
+version = 1
+revision = 3
+requires-python = ">=3.11"
+resolution-markers = [
+    "python_full_version >= '3.15'",
+    "python_full_version < '3.15'",
+]
+
+[options]
+exclude-newer = "0001-01-01T00:00:00Z" # This has no effect and is included for backwards compatibility when using relative exclude-newer values.
+exclude-newer-span = "P7D"
+
+[options.exclude-newer-package]
+uv = { timestamp = "0001-01-01T00:00:00Z", span = "P1D" }
+
+[[package]]
+name = "colorama"
+version = "0.4.6"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
+]
+
+[[package]]
+name = "iniconfig"
+version = "2.3.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
+]
+
+[[package]]
+name = "librt"
+version = "0.9.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e2/1e/2ec7afcebcf3efea593d13aee18bbcfdd3a243043d848ebf385055e9f636/librt-0.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:90904fac73c478f4b83f4ed96c99c8208b75e6f9a8a1910548f69a00f1eaa671", size = 67155, upload-time = "2026-04-09T16:04:42.933Z" },
+    { url = "https://files.pythonhosted.org/packages/18/77/72b85afd4435268338ad4ec6231b3da8c77363f212a0227c1ff3b45e4d35/librt-0.9.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:789fff71757facc0738e8d89e3b84e4f0251c1c975e85e81b152cdaca927cc2d", size = 69916, upload-time = "2026-04-09T16:04:44.042Z" },
+    { url = "https://files.pythonhosted.org/packages/27/fb/948ea0204fbe2e78add6d46b48330e58d39897e425560674aee302dca81c/librt-0.9.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1bf465d1e5b0a27713862441f6467b5ab76385f4ecf8f1f3a44f8aa3c695b4b6", size = 199635, upload-time = "2026-04-09T16:04:45.5Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/cd/894a29e251b296a27957856804cfd21e93c194aa131de8bb8032021be07e/librt-0.9.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f819e0c6413e259a17a7c0d49f97f405abadd3c2a316a3b46c6440b7dbbedbb1", size = 211051, upload-time = "2026-04-09T16:04:47.016Z" },
+    { url = "https://files.pythonhosted.org/packages/18/8f/dcaed0bc084a35f3721ff2d081158db569d2c57ea07d35623ddaca5cfc8e/librt-0.9.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e0785c2fb4a81e1aece366aa3e2e039f4a4d7d21aaaded5227d7f3c703427882", size = 224031, upload-time = "2026-04-09T16:04:48.207Z" },
+    { url = "https://files.pythonhosted.org/packages/03/44/88f6c1ed1132cd418601cc041fbd92fed28b3a09f39de81978e0822d13ff/librt-0.9.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:80b25c7b570a86c03b5da69e665809deb39265476e8e21d96a9328f9762f9990", size = 218069, upload-time = "2026-04-09T16:04:50.025Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/90/7d02e981c2db12188d82b4410ff3e35bfdb844b26aecd02233626f46af2b/librt-0.9.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d4d16b608a1c43d7e33142099a75cd93af482dadce0bf82421e91cad077157f4", size = 224857, upload-time = "2026-04-09T16:04:51.684Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/c3/c77e706b7215ca32e928d47535cf13dbc3d25f096f84ddf8fbc06693e229/librt-0.9.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:194fc1a32e1e21fe809d38b5faea66cc65eaa00217c8901fbdb99866938adbdb", size = 219865, upload-time = "2026-04-09T16:04:52.949Z" },
+    { url = "https://files.pythonhosted.org/packages/52/d1/32b0c1a0eb8461c70c11656c46a29f760b7c7edf3c36d6f102470c17170f/librt-0.9.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:8c6bc1384d9738781cfd41d09ad7f6e8af13cfea2c75ece6bd6d2566cdea2076", size = 218451, upload-time = "2026-04-09T16:04:54.174Z" },
+    { url = "https://files.pythonhosted.org/packages/74/d1/adfd0f9c44761b1d49b1bec66173389834c33ee2bd3c7fd2e2367f1942d4/librt-0.9.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:15cb151e52a044f06e54ac7f7b47adbfc89b5c8e2b63e1175a9d587c43e8942a", size = 241300, upload-time = "2026-04-09T16:04:55.452Z" },
+    { url = "https://files.pythonhosted.org/packages/09/b0/9074b64407712f0003c27f5b1d7655d1438979155f049720e8a1abd9b1a1/librt-0.9.0-cp311-cp311-win32.whl", hash = "sha256:f100bfe2acf8a3689af9d0cc660d89f17286c9c795f9f18f7b62dd1a6b247ae6", size = 55668, upload-time = "2026-04-09T16:04:56.689Z" },
+    { url = "https://files.pythonhosted.org/packages/24/19/40b77b77ce80b9389fb03971431b09b6b913911c38d412059e0b3e2a9ef2/librt-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b73e4266307e51c95e09c0750b7ec383c561d2e97d58e473f6f6a209952fbb8", size = 62976, upload-time = "2026-04-09T16:04:57.733Z" },
+    { url = "https://files.pythonhosted.org/packages/70/9d/9fa7a64041e29035cb8c575af5f0e3840be1b97b4c4d9061e0713f171849/librt-0.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:bc5518873822d2faa8ebdd2c1a4d7c8ef47b01a058495ab7924cb65bdbf5fc9a", size = 53502, upload-time = "2026-04-09T16:04:58.806Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/90/89ddba8e1c20b0922783cd93ed8e64f34dc05ab59c38a9c7e313632e20ff/librt-0.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b3e3bc363f71bda1639a4ee593cb78f7fbfeacc73411ec0d4c92f00730010a4", size = 68332, upload-time = "2026-04-09T16:05:00.09Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/40/7aa4da1fb08bdeeb540cb07bfc8207cb32c5c41642f2594dbd0098a0662d/librt-0.9.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0a09c2f5869649101738653a9b7ab70cf045a1105ac66cbb8f4055e61df78f2d", size = 70581, upload-time = "2026-04-09T16:05:01.213Z" },
+    { url = "https://files.pythonhosted.org/packages/48/ac/73a2187e1031041e93b7e3a25aae37aa6f13b838c550f7e0f06f66766212/librt-0.9.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5ca8e133d799c948db2ab1afc081c333a825b5540475164726dcbf73537e5c2f", size = 203984, upload-time = "2026-04-09T16:05:02.542Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/3d/23460d571e9cbddb405b017681df04c142fb1b04cbfce77c54b08e28b108/librt-0.9.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:603138ee838ee1583f1b960b62d5d0007845c5c423feb68e44648b1359014e27", size = 215762, upload-time = "2026-04-09T16:05:04.127Z" },
+    { url = "https://files.pythonhosted.org/packages/de/1e/42dc7f8ab63e65b20640d058e63e97fd3e482c1edbda3570d813b4d0b927/librt-0.9.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f4003f70c56a5addd6aa0897f200dd59afd3bf7bcd5b3cce46dd21f925743bc2", size = 230288, upload-time = "2026-04-09T16:05:05.883Z" },
+    { url = "https://files.pythonhosted.org/packages/dc/08/ca812b6d8259ad9ece703397f8ad5c03af5b5fedfce64279693d3ce4087c/librt-0.9.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:78042f6facfd98ecb25e9829c7e37cce23363d9d7c83bc5f72702c5059eb082b", size = 224103, upload-time = "2026-04-09T16:05:07.148Z" },
+    { url = "https://files.pythonhosted.org/packages/b6/3f/620490fb2fa66ffd44e7f900254bc110ebec8dac6c1b7514d64662570e6f/librt-0.9.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a361c9434a64d70a7dbb771d1de302c0cc9f13c0bffe1cf7e642152814b35265", size = 232122, upload-time = "2026-04-09T16:05:08.386Z" },
+    { url = "https://files.pythonhosted.org/packages/e9/83/12864700a1b6a8be458cf5d05db209b0d8e94ae281e7ec261dbe616597b4/librt-0.9.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:dd2c7e082b0b92e1baa4da28163a808672485617bc855cc22a2fd06978fa9084", size = 225045, upload-time = "2026-04-09T16:05:09.707Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/1b/845d339c29dc7dbc87a2e992a1ba8d28d25d0e0372f9a0a2ecebde298186/librt-0.9.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7e6274fd33fc5b2a14d41c9119629d3ff395849d8bcbc80cf637d9e8d2034da8", size = 227372, upload-time = "2026-04-09T16:05:10.942Z" },
+    { url = "https://files.pythonhosted.org/packages/8d/fe/277985610269d926a64c606f761d58d3db67b956dbbf40024921e95e7fcb/librt-0.9.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5093043afb226ecfa1400120d1ebd4442b4f99977783e4f4f7248879009b227f", size = 248224, upload-time = "2026-04-09T16:05:12.254Z" },
+    { url = "https://files.pythonhosted.org/packages/92/1b/ee486d244b8de6b8b5dbaefabe6bfdd4a72e08f6353edf7d16d27114da8d/librt-0.9.0-cp312-cp312-win32.whl", hash = "sha256:9edcc35d1cae9fd5320171b1a838c7da8a5c968af31e82ecc3dff30b4be0957f", size = 55986, upload-time = "2026-04-09T16:05:13.529Z" },
+    { url = "https://files.pythonhosted.org/packages/89/7a/ba1737012308c17dc6d5516143b5dce9a2c7ba3474afd54e11f44a4d1ef3/librt-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc2917258e131ae5f958a4d872e07555b51cb7466a43433218061c74ef33745", size = 63260, upload-time = "2026-04-09T16:05:14.68Z" },
+    { url = "https://files.pythonhosted.org/packages/36/e4/01752c113da15127f18f7bf11142f5640038f062407a611c059d0036c6aa/librt-0.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:90e6d5420fc8a300518d4d2288154ff45005e920425c22cbbfe8330f3f754bd9", size = 53694, upload-time = "2026-04-09T16:05:16.095Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/d7/1b3e26fffde1452d82f5666164858a81c26ebe808e7ae8c9c88628981540/librt-0.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f29b68cd9714531672db62cc54f6e8ff981900f824d13fa0e00749189e13778e", size = 68367, upload-time = "2026-04-09T16:05:17.243Z" },
+    { url = "https://files.pythonhosted.org/packages/a5/5b/c61b043ad2e091fbe1f2d35d14795e545d0b56b03edaa390fa1dcee3d160/librt-0.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7d5c8a5929ac325729f6119802070b561f4db793dffc45e9ac750992a4ed4d22", size = 70595, upload-time = "2026-04-09T16:05:18.471Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/22/2448471196d8a73370aa2f23445455dc42712c21404081fcd7a03b9e0749/librt-0.9.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:756775d25ec8345b837ab52effee3ad2f3b2dfd6bbee3e3f029c517bd5d8f05a", size = 204354, upload-time = "2026-04-09T16:05:19.593Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/5e/39fc4b153c78cfd2c8a2dcb32700f2d41d2312aa1050513183be4540930d/librt-0.9.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b8f5d00b49818f4e2b1667db994488b045835e0ac16fe2f924f3871bd2b8ac5", size = 216238, upload-time = "2026-04-09T16:05:20.868Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/42/bc2d02d0fa7badfa63aa8d6dcd8793a9f7ef5a94396801684a51ed8d8287/librt-0.9.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c81aef782380f0f13ead670aae01825eb653b44b046aa0e5ebbb79f76ed4aa11", size = 230589, upload-time = "2026-04-09T16:05:22.305Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/7b/e2d95cc513866373692aa5edf98080d5602dd07cabfb9e5d2f70df2f25f7/librt-0.9.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66b58fed90a545328e80d575467244de3741e088c1af928f0b489ebec3ef3858", size = 224610, upload-time = "2026-04-09T16:05:23.647Z" },
+    { url = "https://files.pythonhosted.org/packages/31/d5/6cec4607e998eaba57564d06a1295c21b0a0c8de76e4e74d699e627bd98c/librt-0.9.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e78fb7419e07d98c2af4b8567b72b3eaf8cb05caad642e9963465569c8b2d87e", size = 232558, upload-time = "2026-04-09T16:05:25.025Z" },
+    { url = "https://files.pythonhosted.org/packages/95/8c/27f1d8d3aaf079d3eb26439bf0b32f1482340c3552e324f7db9dca858671/librt-0.9.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2c3786f0f4490a5cd87f1ed6cefae833ad6b1060d52044ce0434a2e85893afd0", size = 225521, upload-time = "2026-04-09T16:05:26.311Z" },
+    { url = "https://files.pythonhosted.org/packages/6b/d8/1e0d43b1c329b416017619469b3c3801a25a6a4ef4a1c68332aeaa6f72ca/librt-0.9.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8494cfc61e03542f2d381e71804990b3931175a29b9278fdb4a5459948778dc2", size = 227789, upload-time = "2026-04-09T16:05:27.624Z" },
+    { url = "https://files.pythonhosted.org/packages/2c/b4/d3d842e88610fcd4c8eec7067b0c23ef2d7d3bff31496eded6a83b0f99be/librt-0.9.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:07cf11f769831186eeac424376e6189f20ace4f7263e2134bdb9757340d84d4d", size = 248616, upload-time = "2026-04-09T16:05:29.181Z" },
+    { url = "https://files.pythonhosted.org/packages/ec/28/527df8ad0d1eb6c8bdfa82fc190f1f7c4cca5a1b6d7b36aeabf95b52d74d/librt-0.9.0-cp313-cp313-win32.whl", hash = "sha256:850d6d03177e52700af605fd60db7f37dcb89782049a149674d1a9649c2138fd", size = 56039, upload-time = "2026-04-09T16:05:30.709Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/a7/413652ad0d92273ee5e30c000fc494b361171177c83e57c060ecd3c21538/librt-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:a5af136bfba820d592f86c67affcef9b3ff4d4360ac3255e341e964489b48519", size = 63264, upload-time = "2026-04-09T16:05:31.881Z" },
+    { url = "https://files.pythonhosted.org/packages/a4/0a/92c244309b774e290ddb15e93363846ae7aa753d9586b8aad511c5e6145b/librt-0.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:4c4d0440a3a8e31d962340c3e1cc3fc9ee7febd34c8d8f770d06adb947779ea5", size = 53728, upload-time = "2026-04-09T16:05:33.31Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" },
+    { url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" },
+    { url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" },
+    { url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" },
+    { url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" },
+    { url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" },
+    { url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" },
+    { url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" },
+    { url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" },
+    { url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" },
+    { url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" },
+    { url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" },
+    { url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" },
+    { url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" },
+    { url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" },
+    { url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" },
+    { url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" },
+]
+
+[[package]]
+name = "mypy"
+version = "1.20.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "librt", marker = "platform_python_implementation != 'PyPy'" },
+    { name = "mypy-extensions" },
+    { name = "pathspec" },
+    { name = "typing-extensions" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/1f/4d/9ebeae211caccbdaddde7ed5e31dfcf57faac66be9b11deb1dc6526c8078/mypy-1.20.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4077797a273e56e8843d001e9dfe4ba10e33323d6ade647ff260e5cd97d9758c", size = 14371307, upload-time = "2026-04-21T17:08:56.442Z" },
+    { url = "https://files.pythonhosted.org/packages/95/d7/93473d34b61f04fac1aecc01368485c89c5c4af7a4b9a0cab5d77d04b63f/mypy-1.20.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:cdecf62abcc4292500d7858aeae87a1f8f1150f4c4dd08fb0b336ee79b2a6df3", size = 13258917, upload-time = "2026-04-21T17:05:50.978Z" },
+    { url = "https://files.pythonhosted.org/packages/e2/30/3dd903e8bafb7b5f7bf87fcd58f8382086dea2aa19f0a7b357f21f63071b/mypy-1.20.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c566c3a88b6ece59b3d70f65bedef17304f48eb52ff040a6a18214e1917b3254", size = 13700516, upload-time = "2026-04-21T17:11:33.161Z" },
+    { url = "https://files.pythonhosted.org/packages/07/05/c61a140aba4c729ac7bc99ae26fc627c78a6e08f5b9dd319244ea71a3d7e/mypy-1.20.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0deb80d062b2479f2c87ae568f89845afc71d11bc41b04179e58165fd9f31e98", size = 14562889, upload-time = "2026-04-21T17:05:27.674Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/87/da78243742ffa8a36d98c3010f0d829f93d5da4e6786f1a1a6f2ad616502/mypy-1.20.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bba9ad231e92a3e424b3e56b65aa17704993425bba97e302c832f9466bb85bac", size = 14803844, upload-time = "2026-04-21T17:10:06.2Z" },
+    { url = "https://files.pythonhosted.org/packages/37/52/10a1ddf91b40f843943a3c6db51e2df59c9e237f29d355e95eaab427461f/mypy-1.20.2-cp311-cp311-win_amd64.whl", hash = "sha256:baf593f2765fa3a6b1ef95807dbaa3d25b594f6a52adcc506a6b9cb115e1be67", size = 10846300, upload-time = "2026-04-21T17:12:23.886Z" },
+    { url = "https://files.pythonhosted.org/packages/20/02/f9a4415b664c53bd34d6709be59da303abcae986dc4ac847b402edb6fa1e/mypy-1.20.2-cp311-cp311-win_arm64.whl", hash = "sha256:20175a1c0f49863946ec20b7f63255768058ac4f07d2b9ded6a6b46cfb5a9100", size = 9779498, upload-time = "2026-04-21T17:09:23.695Z" },
+    { url = "https://files.pythonhosted.org/packages/71/4e/7560e4528db9e9b147e4c0f22660466bf30a0a1fe3d63d1b9d3b0fd354ee/mypy-1.20.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4dbfcf869f6b0517f70cf0030ba6ea1d6645e132337a7d5204a18d8d5636c02b", size = 14539393, upload-time = "2026-04-21T17:07:12.52Z" },
+    { url = "https://files.pythonhosted.org/packages/32/d9/34a5efed8124f5a9234f55ac6a4ced4201e2c5b81e1109c49ad23190ec8c/mypy-1.20.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4b6481b228d072315b053210b01ac320e1be243dc17f9e5887ef167f23f5fae4", size = 13361642, upload-time = "2026-04-21T17:06:53.742Z" },
+    { url = "https://files.pythonhosted.org/packages/d1/14/eb377acf78c03c92d566a1510cda8137348215b5335085ef662ab82ecd3a/mypy-1.20.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:34397cdced6b90b836e38182076049fdb41424322e0b0728c946b0939ebdf9f6", size = 13740347, upload-time = "2026-04-21T17:12:04.73Z" },
+    { url = "https://files.pythonhosted.org/packages/b9/94/7e4634a32b641aa1c112422eed1bbece61ee16205f674190e8b536f884de/mypy-1.20.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5da6976f20cae27059ea8d0c86e7cef3de720e04c4bb9ee18e3690fdb792066", size = 14734042, upload-time = "2026-04-21T17:07:43.16Z" },
+    { url = "https://files.pythonhosted.org/packages/7a/f3/f7e62395cb7f434541b4491a01149a4439e28ace4c0c632bbf5431e92d1f/mypy-1.20.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:56908d7e08318d39f85b1f0c6cfd47b0cac1a130da677630dac0de3e0623e102", size = 14964958, upload-time = "2026-04-21T17:11:00.665Z" },
+    { url = "https://files.pythonhosted.org/packages/3e/0d/47e3c3a0ec2a876e35aeac365df3cac7776c36bbd4ed18cc521e1b9d255b/mypy-1.20.2-cp312-cp312-win_amd64.whl", hash = "sha256:d52ad8d78522da1d308789df651ee5379088e77c76cb1994858d40a426b343b9", size = 10911340, upload-time = "2026-04-21T17:10:49.179Z" },
+    { url = "https://files.pythonhosted.org/packages/d6/b2/6c852d72e0ea8b01f49da817fb52539993cde327e7d010e0103dc12d0dac/mypy-1.20.2-cp312-cp312-win_arm64.whl", hash = "sha256:785b08db19c9f214dc37d65f7c165d19a30fcecb48abfa30f31b01b5acaabb58", size = 9833947, upload-time = "2026-04-21T17:09:05.267Z" },
+    { url = "https://files.pythonhosted.org/packages/5b/c4/b93812d3a192c9bcf5df405bd2f30277cd0e48106a14d1023c7f6ed6e39b/mypy-1.20.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:edfbfca868cdd6bd8d974a60f8a3682f5565d3f5c99b327640cedd24c4264026", size = 14524670, upload-time = "2026-04-21T17:10:30.737Z" },
+    { url = "https://files.pythonhosted.org/packages/f3/47/42c122501bff18eaf1e8f457f5c017933452d8acdc52918a9f59f6812955/mypy-1.20.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e2877a02380adfcdbc69071a0f74d6e9dbbf593c0dc9d174e1f223ffd5281943", size = 13336218, upload-time = "2026-04-21T17:08:44.069Z" },
+    { url = "https://files.pythonhosted.org/packages/92/8f/75bbc92f41725fbd585fb17b440b1119b576105df1013622983e18640a93/mypy-1.20.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7488448de6007cd5177c6cea0517ac33b4c0f5ee9b5e9f2be51ce75511a85517", size = 13724906, upload-time = "2026-04-21T17:08:01.02Z" },
+    { url = "https://files.pythonhosted.org/packages/a1/32/4c49da27a606167391ff0c39aa955707a00edc500572e562f7c36c08a71f/mypy-1.20.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb9c2fa06887e21d6a3a868762acb82aec34e2c6fd0174064f27c93ede68ad15", size = 14726046, upload-time = "2026-04-21T17:11:22.354Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/fc/4e354a1bd70216359deb0c9c54847ee6b32ef78dfb09f5131ff99b494078/mypy-1.20.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d56a78b646f2e3daa865bc70cd5ec5a46c50045801ca8ff17a0c43abc97e3ee", size = 14955587, upload-time = "2026-04-21T17:12:16.033Z" },
+    { url = "https://files.pythonhosted.org/packages/62/b2/c0f2056e9eb8f08c62cafd9715e4584b89132bdc832fcf85d27d07b5f3e5/mypy-1.20.2-cp313-cp313-win_amd64.whl", hash = "sha256:2a4102b03bb7481d9a91a6da8d174740c9c8c4401024684b9ca3b7cc5e49852f", size = 10922681, upload-time = "2026-04-21T17:06:35.842Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/14/065e333721f05de8ef683d0aa804c23026bcc287446b61cac657b902ccac/mypy-1.20.2-cp313-cp313-win_arm64.whl", hash = "sha256:a95a9248b0c6fd933a442c03c3b113c3b61320086b88e2c444676d3fd1ca3330", size = 9830560, upload-time = "2026-04-21T17:07:51.023Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
+    { url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
+    { url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
+    { url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
+    { url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
+    { url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
+    { url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
+    { url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
+    { url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
+    { url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
+    { url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
+    { url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
+    { url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
+]
+
+[[package]]
+name = "mypy-extensions"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
+]
+
+[[package]]
+name = "packaging"
+version = "26.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
+]
+
+[[package]]
+name = "pathspec"
+version = "1.1.1"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
+]
+
+[[package]]
+name = "pluggy"
+version = "1.6.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
+]
+
+[[package]]
+name = "pygments"
+version = "2.20.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
+]
+
+[[package]]
+name = "pytest"
+version = "9.0.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "colorama", marker = "sys_platform == 'win32'" },
+    { name = "iniconfig" },
+    { name = "packaging" },
+    { name = "pluggy" },
+    { name = "pygments" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
+]
+
+[[package]]
+name = "redactor"
+version = "0.1.0"
+source = { editable = "." }
+
+[package.dev-dependencies]
+dev = [
+    { name = "mypy" },
+    { name = "pytest" },
+    { name = "ruff" },
+]
+
+[package.metadata]
+
+[package.metadata.requires-dev]
+dev = [
+    { name = "mypy", specifier = ">=1.11" },
+    { name = "pytest", specifier = ">=8.0" },
+    { name = "ruff", specifier = ">=0.6" },
+]
+
+[[package]]
+name = "ruff"
+version = "0.15.12"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
+    { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
+    { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
+    { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
+    { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
+    { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
+    { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
+    { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
+    { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
+    { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
+    { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
+    { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
+    { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
+]
+
+[[package]]
+name = "typing-extensions"
+version = "4.15.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
+]
diff --git a/tools/privacy-llm/tool.md b/tools/privacy-llm/tool.md
new file mode 100644
index 0000000..45109b5
--- /dev/null
+++ b/tools/privacy-llm/tool.md
@@ -0,0 +1,139 @@
+<!-- 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)*
+
+- [Tool: privacy-llm](#tool-privacy-llm)
+  - [What this tool provides](#what-this-tool-provides)
+  - [Why this is its own tool](#why-this-is-its-own-tool)
+  - [The two mechanisms — what is gated, and what is redacted](#the-two-mechanisms--what-is-gated-and-what-is-redacted)
+  - [How skills consume this tool](#how-skills-consume-this-tool)
+  - [What this tool is NOT for](#what-this-tool-is-not-for)
+  - [Failure modes](#failure-modes)
+
+<!-- END doctoc generated TOC please keep comment here to allow auto update -->
+
+<!-- SPDX-License-Identifier: Apache-2.0
+     https://www.apache.org/legal/release-policy.html -->
+
+# Tool: privacy-llm
+
+This directory documents the **privacy-llm** tool adapter — the
+contract every framework skill follows when it touches private mail
+content (`<security-list>` reporter mail, `<private-list>` PMC
+escalation mail) or routes that content through any LLM step.
+
+The tool exists because two distinct, easily-confused privacy
+concerns share a single workflow:
+
+1. **Reporter PII** in `<security-list>` content — the body is OK to
+   process, but the reporter's *identity* (name, email, phone, IP,
+   personal handles) must never enter any LLM in the clear.
+2. **Wholly private list content** on `<private-list>` and other
+   PMC-private foundation lists — the body itself must never reach
+   a non-approved LLM.
+
+These two concerns produce different remediations. Mixing them up
+(e.g. assuming PII redaction is enough for `<private-list>` content)
+is a real foot-gun the framework guards against.
+
+A project opts into this tool by naming it in its manifest under
+*Tools enabled*. For the adopting project see
+[`../../<project-config>/project.md`](../../<project-config>/project.md#tools-enabled).
+The tool is **mandatory** for any project that uses `tools/gmail/`
+or `tools/ponymail/` — those tools surface private content into the
+agent's context, and `privacy-llm` is what stops it from leaking.
+
+## What this tool provides
+
+| 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. |
+| 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. |
+
+## Why this is its own tool
+
+The redaction lifecycle is **cross-cutting**: it sits between every
+private-data fetch tool (Gmail, PonyMail, future mail backends) and
+every LLM consumer of that data (Claude itself, any future
+delegated-summarization step, any non-Claude analysis hop). Hosting
+it under `tools/gmail/` or `tools/ponymail/` would couple it to one
+backend; hosting it under any single skill would create N copies
+that drift. A dedicated `tools/privacy-llm/` directory keeps the
+contract in one place and the helper code in one project.
+
+The same argument applies to the approved-model registry — it gates
+*outbound* LLM calls regardless of which skill is making the call,
+so it must live above the per-skill / per-tool layer.
+
+## The two mechanisms — what is gated, and what is redacted
+
+The framework's two privacy mechanisms apply to different data
+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. |
+| `<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. |
+
+The decision tree the skill follows on every fetch is captured in
+[`pii.md`](pii.md) (redaction lifecycle) and [`models.md`](models.md)
+(gate semantics).
+
+## How skills consume this tool
+
+Three integration points:
+
+1. **Step 0 — pre-flight gate.** Skills that may read
+   `<private-list>` content (`security-issue-import`,
+   `security-issue-sync`, `security-issue-fix`,
+   `security-issue-deduplicate` when escalating to PMC) read the
+   adopter's `<project-config>/privacy-llm.md` and refuse to run if
+   no approved model is wired. The check is implemented per
+   [`models.md`](models.md#the-pre-flight-check).
+2. **After fetch — redact PII.** Any skill that fetches Gmail or
+   PonyMail content shells out to `pii-redact` on the body before
+   doing further processing. The redactor is idempotent — running
+   twice on already-redacted text produces the same identifiers and
+   does not double-encode.
+3. **Before send — reveal identifiers.** When assembling a draft
+   that needs real names (the reporter reply, a CVE credit line),
+   shell out to `pii-reveal` on the rendered draft text. Reveal
+   only happens at the *outbound boundary* — the LLM step that
+   composes the draft operates on identifiers throughout.
+
+Concrete invocation patterns are in
+[`pii.md`](pii.md#how-skills-call-the-redactor) and
+[`models.md`](models.md#how-skills-call-the-gate).
+
+## What this tool is NOT for
+
+- **Not a substitute for the existing confidentiality rules in
+  [`AGENTS.md`](../../AGENTS.md#confidentiality-of-the-tracker-repository).**
+  Those rules govern *human-visible* surfaces (public PRs, public
+  issue comments, public mailing-list replies). `privacy-llm`
+  governs *machine-routed* surfaces (LLM context, LLM API calls,
+  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.
+- **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
+  MCP middleware. If a future MCP gains hook support, the skill
+  layer can move the redactor call into the hook without changing
+  the contract.
+
+## Failure modes
+
+| 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-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 |