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 |