← back to Cli Printing Press
feat(cli): mechanical PII gate before promote/publish (#958) (#1023)
a3e07d0af8cba3d7270f44c231cf00096bb29e5f · 2026-05-10 23:30:54 -0700 · Trevin Chow
* feat(cli): mechanical PII gate before promote/publish (#958)
Adds a customer-PII gate using the Deterministic Inventory + Agent-Marked
Ledger pattern (docs/PATTERNS.md), mirroring tools-audit / public-param-audit.
Closes the leak path documented in the amazon-orders retro #955 (real
customer values reached the published library because publish had no
content scan beyond vendor-prefix tokens).
Architecture:
- internal/artifacts/pii.go — Phase-1 detectors (card-last-4 with context
tokens, email, US phone, ZIP+4, postal-address Title-Case+ALL-CAPS) +
ledger I/O with atomic temp-rename writes + enforcement primitives
(pre-decision fields, duplicate-rationale cap, all-accepted-no-fixes).
- internal/cli/pii_audit.go — `printing-press pii-audit <cli-dir>`
subcommand. Exit 0 by default (diagnostic); --strict exits 3 to match
public-param-audit. --json is a read-only probe; non-JSON writes the
ledger.
- internal/pipeline/lock.go — PromoteWorkingCLI invokes the audit before
CopyDir; refuses on pending findings or gate failures with a pointer
to the ledger and the pii-polish playbook.
- internal/cli/publish.go — publish-package runs the audit alongside the
existing vendor-prefix secret scan; combined error report.
- skills/printing-press-polish/SKILL.md — Priority 7 PII gate step,
baseline/re-diagnose passes, and ship-logic update.
- skills/printing-press-polish/references/pii-polish.md — agent playbook
for walking the ledger (per-finding decision, accept contract,
forbidden patterns).
Scope cuts (documented in plan + error messages):
- Phase-1 detectors are shape-only. Order/transaction IDs, ASINs, and
standalone names defer to #960 (spec-aware detection).
- File scope: `*.json`, `*.yaml`, `*.md`, `*_test.go`, `.manuscripts/**`,
`testdata/**`, `README.md`. Non-test Go source and module metadata
excluded by default.
- Multi-line pretty-printed JSON detection (key on one line, value on
next) is a known gap; tracked for a future streaming-parser pass.
Tests: 3561 pass across 34 packages. New tests cover detector behavior,
file scoping, finding-ID stability, ledger I/O (including atomic write
+ stale-preservation), the four enforcement primitives, the promote and
publish gate halt/pass paths, the duplicate-rationale threshold boundary,
the all-accepted-no-fixes incremental-add bypass guard, and regression
guards for the card-substring (`discard 1234`) + postal-address
conversational-prose false-positive shapes the review surfaced.
Plan: docs/plans/2026-05-10-002-feat-pii-redaction-before-promote-plan.md
* fix(cli): address PR-toolkit review findings on PII gate (#958)
Followup to the PR-review-toolkit (code/test/silent-failure/type-design/
comment) pass on top of the multi-persona ce-code-review.
AGENTS.md hygiene — drop ticket numbers from user-facing strings and
incident name from skill content:
- pii_audit.go and lock.go: '#960' → 'a future detector class' in
no-pending-findings / gate-failure messages
- pii-polish.md: same
- SKILL.md: 'amazon-orders retro #955' → generic phrasing
Comment/code correctness:
- validatePIIGateForPromote doc-comment falsely claimed 'clean working
dir passes with no ledger write effects' — RunPIIAudit always writes
the ledger to refresh the timestamp and FindingsCountBefore baseline.
Rewrote to reflect actual behavior.
- newPIIAuditCmd doc-comment falsely claimed promote/publish gates use
--strict — they call artifacts.RunPIIAudit directly in-process.
Rewrote to credit external CI callers.
- formatCombinedScanError comment listed 2 sections; code emits 3.
- IncompleteAccepts field comment and gate-failure message understated
the predicate (also flags invalid category + other-without-note).
Error context:
- lock.go validatePIIGateForPromote and publish.go publish-package now
wrap the audit error with 'PII gate failed (scan error)' /
'scanning staged package for PII' to match the phase5-gate and
vendor-prefix-secret sibling error shapes.
Dead code:
- Removed unreachable IsStalePIILedger check in pii-audit's non-JSON
path. RunPIIAudit refreshes the timestamp before the check fires, so
the prior staleness signal is gone. Helper remains exported for
future callers that read before audit.
Tests: 3561 still pass. Updated one substring assertion in
TestPIIAuditCmd_StrictFailsOnAcceptMissingCategory to match the
expanded 'pre-decision fields' wording (the pr-test-analyzer review
called out this assertion as fragile on the prior wording).
Files touched
A docs/plans/2026-05-10-002-feat-pii-redaction-before-promote-plan.mdA internal/artifacts/pii.goA internal/artifacts/pii_test.goA internal/cli/pii_audit.goA internal/cli/pii_audit_test.goM internal/cli/publish.goM internal/cli/publish_test.goM internal/cli/root.goM internal/cli/tools_audit.goM internal/pipeline/lock.goM internal/pipeline/lock_test.goM skills/printing-press-polish/SKILL.mdA skills/printing-press-polish/references/pii-polish.md
Diff
commit a3e07d0af8cba3d7270f44c231cf00096bb29e5f
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 10 23:30:54 2026 -0700
feat(cli): mechanical PII gate before promote/publish (#958) (#1023)
* feat(cli): mechanical PII gate before promote/publish (#958)
Adds a customer-PII gate using the Deterministic Inventory + Agent-Marked
Ledger pattern (docs/PATTERNS.md), mirroring tools-audit / public-param-audit.
Closes the leak path documented in the amazon-orders retro #955 (real
customer values reached the published library because publish had no
content scan beyond vendor-prefix tokens).
Architecture:
- internal/artifacts/pii.go — Phase-1 detectors (card-last-4 with context
tokens, email, US phone, ZIP+4, postal-address Title-Case+ALL-CAPS) +
ledger I/O with atomic temp-rename writes + enforcement primitives
(pre-decision fields, duplicate-rationale cap, all-accepted-no-fixes).
- internal/cli/pii_audit.go — `printing-press pii-audit <cli-dir>`
subcommand. Exit 0 by default (diagnostic); --strict exits 3 to match
public-param-audit. --json is a read-only probe; non-JSON writes the
ledger.
- internal/pipeline/lock.go — PromoteWorkingCLI invokes the audit before
CopyDir; refuses on pending findings or gate failures with a pointer
to the ledger and the pii-polish playbook.
- internal/cli/publish.go — publish-package runs the audit alongside the
existing vendor-prefix secret scan; combined error report.
- skills/printing-press-polish/SKILL.md — Priority 7 PII gate step,
baseline/re-diagnose passes, and ship-logic update.
- skills/printing-press-polish/references/pii-polish.md — agent playbook
for walking the ledger (per-finding decision, accept contract,
forbidden patterns).
Scope cuts (documented in plan + error messages):
- Phase-1 detectors are shape-only. Order/transaction IDs, ASINs, and
standalone names defer to #960 (spec-aware detection).
- File scope: `*.json`, `*.yaml`, `*.md`, `*_test.go`, `.manuscripts/**`,
`testdata/**`, `README.md`. Non-test Go source and module metadata
excluded by default.
- Multi-line pretty-printed JSON detection (key on one line, value on
next) is a known gap; tracked for a future streaming-parser pass.
Tests: 3561 pass across 34 packages. New tests cover detector behavior,
file scoping, finding-ID stability, ledger I/O (including atomic write
+ stale-preservation), the four enforcement primitives, the promote and
publish gate halt/pass paths, the duplicate-rationale threshold boundary,
the all-accepted-no-fixes incremental-add bypass guard, and regression
guards for the card-substring (`discard 1234`) + postal-address
conversational-prose false-positive shapes the review surfaced.
Plan: docs/plans/2026-05-10-002-feat-pii-redaction-before-promote-plan.md
* fix(cli): address PR-toolkit review findings on PII gate (#958)
Followup to the PR-review-toolkit (code/test/silent-failure/type-design/
comment) pass on top of the multi-persona ce-code-review.
AGENTS.md hygiene — drop ticket numbers from user-facing strings and
incident name from skill content:
- pii_audit.go and lock.go: '#960' → 'a future detector class' in
no-pending-findings / gate-failure messages
- pii-polish.md: same
- SKILL.md: 'amazon-orders retro #955' → generic phrasing
Comment/code correctness:
- validatePIIGateForPromote doc-comment falsely claimed 'clean working
dir passes with no ledger write effects' — RunPIIAudit always writes
the ledger to refresh the timestamp and FindingsCountBefore baseline.
Rewrote to reflect actual behavior.
- newPIIAuditCmd doc-comment falsely claimed promote/publish gates use
--strict — they call artifacts.RunPIIAudit directly in-process.
Rewrote to credit external CI callers.
- formatCombinedScanError comment listed 2 sections; code emits 3.
- IncompleteAccepts field comment and gate-failure message understated
the predicate (also flags invalid category + other-without-note).
Error context:
- lock.go validatePIIGateForPromote and publish.go publish-package now
wrap the audit error with 'PII gate failed (scan error)' /
'scanning staged package for PII' to match the phase5-gate and
vendor-prefix-secret sibling error shapes.
Dead code:
- Removed unreachable IsStalePIILedger check in pii-audit's non-JSON
path. RunPIIAudit refreshes the timestamp before the check fires, so
the prior staleness signal is gone. Helper remains exported for
future callers that read before audit.
Tests: 3561 still pass. Updated one substring assertion in
TestPIIAuditCmd_StrictFailsOnAcceptMissingCategory to match the
expanded 'pre-decision fields' wording (the pr-test-analyzer review
called out this assertion as fragile on the prior wording).
---
...0-002-feat-pii-redaction-before-promote-plan.md | 449 ++++++++++++
internal/artifacts/pii.go | 800 +++++++++++++++++++++
internal/artifacts/pii_test.go | 650 +++++++++++++++++
internal/cli/pii_audit.go | 154 ++++
internal/cli/pii_audit_test.go | 205 ++++++
internal/cli/publish.go | 46 +-
internal/cli/publish_test.go | 75 ++
internal/cli/root.go | 1 +
internal/cli/tools_audit.go | 3 +-
internal/pipeline/lock.go | 44 ++
internal/pipeline/lock_test.go | 163 +++++
skills/printing-press-polish/SKILL.md | 22 +-
.../printing-press-polish/references/pii-polish.md | 95 +++
13 files changed, 2698 insertions(+), 9 deletions(-)
diff --git a/docs/plans/2026-05-10-002-feat-pii-redaction-before-promote-plan.md b/docs/plans/2026-05-10-002-feat-pii-redaction-before-promote-plan.md
new file mode 100644
index 00000000..d6a3ce2b
--- /dev/null
+++ b/docs/plans/2026-05-10-002-feat-pii-redaction-before-promote-plan.md
@@ -0,0 +1,449 @@
+---
+title: 'feat: Deterministic PII inventory + agent-marked ledger gate before promote/publish'
+type: feat
+status: active
+date: 2026-05-10
+---
+
+# feat: Deterministic PII inventory + agent-marked ledger gate before promote/publish
+
+## Summary
+
+Build a PII gate using the **Deterministic Inventory + Agent-Marked Ledger** pattern documented in [`docs/PATTERNS.md`](../PATTERNS.md) — the same shape `printing-press tools-audit` and `printing-press public-param-audit` already use. A new `printing-press pii-audit <cli-dir>` subcommand walks high-risk files with deterministic regex detectors and emits a persistent ledger of candidate findings. The agent layer (a new `pii-polish.md` reference in the polish skill) walks the ledger and either fixes findings in source (auto-removed next run) or marks them `accepted` with pre-decision fields explaining why. Promote and publish gates invoke the audit and refuse if any finding is pending or if enforcement primitives flag the run incomplete. No auto-mutation of files at the gate.
+
+---
+
+## Problem Frame
+
+Issue [#958](https://github.com/mvanhorn/cli-printing-press/issues/958) (P1, sub-issue of the amazon-orders retro [#955](https://github.com/mvanhorn/cli-printing-press/issues/955)): real customer PII captured during browser-sniff sessions has reached published printed CLIs. The amazon-orders retro found 4 files with real order IDs, a real card last-4, and 8 real ASINs — caught only by an ad-hoc `grep` against the public PR clone.
+
+The Printing Press already scans for vendor-prefix credentials (`sk_*`, `ghp_*`, `xoxb-*`, etc.) at publish via [`internal/artifacts/secrets.go`](../../internal/artifacts/secrets.go) and [`internal/cli/publish.go:382`](../../internal/cli/publish.go#L382). It does not scan for customer PII at any gate, and [`internal/pipeline/lock.go:259`](../../internal/pipeline/lock.go#L259) (`CopyDir(workingDir, stagingDir)`) runs unguarded.
+
+**Why the prior plan was wrong:** an earlier draft of this plan proposed pure mechanical detection + auto-redaction triggered by a `--accept-redaction-list` flag. Doc review revealed three structural problems that pushed the approach toward fool's-errand territory:
+
+1. **Person-name detection is not solvable at regex level.** "Two capitalized words in a JSON value" matches "New York", "Acme Corp", "Test User", "Apple Watch" — names of places, companies, products, and examples are indistinguishable from real PII by shape alone. Microsoft Presidio uses an ML NER model + names lookup database and still runs at ~75-85% precision; regex lands at 30-50% in real code.
+2. **Auto-redaction silently mutates files.** Agent operators reflexively pass `--accept-redaction-list=<all-findings>` to clear halts, corrupting legitimate content (markdown bullets, attribution metadata) with no review trail.
+3. **Ephemeral CLI-flag acks are not an audit trail.** "More auditable than blanket bypass" was the framing, but no acknowledgment was persisted anywhere — `git reflog` of shell history is not an audit log.
+
+The codebase already has a pattern that solves exactly this shape — **mechanical detection plus per-item agent judgment with persisted rationale**. `tools-audit` does this for thin MCP descriptions; `public-param-audit` does it for cryptic wire parameters. This plan applies the same shape to PII.
+
+Phase-1 detector families remain shape-only and require no spec-schema work. Spec-aware detectors (order/transaction ID shapes from `auth.id_shapes`, cookie name+value from `auth.cookies`, ASIN-style entity IDs) defer to [#960](https://github.com/mvanhorn/cli-printing-press/issues/960).
+
+---
+
+## Requirements
+
+Carried forward from issue #958 acceptance criteria (F1 positive/negative), restated to fit the ledger pattern:
+
+- **R1.** A new content-scan gate runs before promote. When the audit's ledger contains pending findings (or enforcement primitives flag the run incomplete), promote halts with a pointer to the ledger and a summary of finding categories. (issue F1 positive #1)
+- **R2.** Detectors run only against high-risk file paths (`**/*.json`, `**/*.yaml`, `**/*.yml`, `**/*.md`, `**/*_test.go`, `**/.manuscripts/**`, `**/testdata/**`, `README.md`). Generator-emitted artifacts and non-test Go source are excluded by default.
+- **R3.** Phase-1 detector families: card last-4 (with required context tokens), email address, US phone number, ZIP+4, postal-address line. Person-name detection is intentionally omitted — see KTD-3.
+- **R4.** A clean OpenAPI-spec CLI (no browser-sniff, no captured PII) passes both gates: audit emits zero findings; ledger is empty. (issue F1 negative #1)
+- **R5.** A browser-sniff CLI whose captured values are already synthetic placeholders passes: audit emits zero findings. (issue F1 negative #2)
+- **R6.** Per-finding agent decisions persist in a ledger file (`<cli-dir>/.printing-press-pii-polish.json`) that survives across runs. Finding identity (file, line, kind, normalized matched span) is the stable key — re-runs preserve agent `status`/`note`/pre-decision fields whose identity key matches.
+- **R7.** Enforcement primitives (mirroring `tools-audit`) gate the "done" state: pre-decision fields required for accept on every kind, duplicate-rationale rejection above threshold 5, delta tracking between runs.
+- **R8.** PII audit runs at publish alongside the existing vendor-prefix secret scan. The two are independent — neither replaces the other, and both must clear for publish to proceed.
+
+---
+
+## Key Technical Decisions
+
+### KTD-1. Apply the Deterministic Inventory + Agent-Marked Ledger pattern
+
+The work has the canonical shape: mechanical detection, per-item judgment, persisted rationale. Existing implementations in [`internal/cli/tools_audit.go`](../../internal/cli/tools_audit.go) and [`internal/cli/public_param_audit.go`](../../internal/cli/public_param_audit.go) provide the structural template; the polish skill at [`skills/printing-press-polish/references/tools-polish.md`](../../skills/printing-press-polish/references/tools-polish.md) provides the agent-side template.
+
+The binary emits a ledger; the agent walks it. The agent makes fixes in source files (auto-removed next run) or marks findings accepted with rationale. No mutation at gate time. No CLI flag for acceptance — accepts live in the ledger.
+
+### KTD-2. File-scoping shrinks the false-positive surface to the actual leak vectors
+
+The amazon-orders retro identified the leak path as captured browser-sniff content flowing into a narrow file set: `internal/parser/parser_test.go`, `internal/mcp/tools.go` (generated from spec parameter examples), `.manuscripts/<run>/research/<api>-browser-sniff-spec.yaml`, `README.md`. Detectors run only against the high-risk file globs in R2. Excluded: non-test Go source (`*.go` excluding `*_test.go`), `go.mod`, `go.sum`, `tools-manifest.json` (generator-emitted from spec), and the generated MCP shim files.
+
+This is a deliberate scope cut. A name leaked into hand-written novel `*.go` source isn't caught by Phase 1 — that path is rare enough that the false-positive cost of broader scanning isn't worth it. If a real leak surfaces in non-test Go source, file a follow-up; the file-glob set is configurable.
+
+### KTD-3. Person-name detection deferred — regex precision is unrecoverable
+
+The agent-marked ledger pattern hedges low-precision detection by adding per-item judgment, but only when finding volume is manageable. Person-name detection over arbitrary JSON values produces hundreds of findings per CLI from "New York", "Acme Corp", "Test User", "Apple Watch", etc. Even with agent judgment, the per-CLI walk-through cost is disproportionate to the leak signal — the parent retro's name leaks were *adjacent to* postal-address values, so the address detector catches the leak shape by proximity.
+
+Defer person-name detection to a follow-up that uses NER (Presidio Go bridge or equivalent), tracked separately from #960. The Phase-1 cut is: emails, phones, card-last-4, ZIP+4, postal-address-line.
+
+### KTD-4. Detector list with context-token requirements
+
+Each detector is high-precision because false-positive cost falls on the agent walker:
+
+- **Card last-4:** `(?i)(card|visa|mastercard|amex|ending in|last\s+4|x{4,}|\*{4,})[\s:.-]{0,5}\d{4}` — requires a context token within a few characters. Bare `\d{4}` is too noisy even with agent judgment.
+- **Email:** `\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b` — high precision; accept TLD validation.
+- **US phone:** `(?:\+?1[\s.-]?)?\(?\d{3}\)?[\s.-]?\d{3}[\s.-]?\d{4}` — medium precision; agent judges (toll-free, fake-numbers, support-line examples).
+- **ZIP+4:** `\b\d{5}-\d{4}\b` — emitted but the agent layer rejects when not in address context. Common false positives: API request IDs, batch identifiers.
+- **Postal-address line:** `(?i)\d+\s+[A-Z][A-Z\s]{3,30}(?:ST|STREET|AVE|AVENUE|RD|ROAD|BLVD|BOULEVARD|DR|DRIVE|LN|LANE|CT|COURT|PL|PLACE|WAY)\b` — street-number + ALL-CAPS street name + suffix.
+
+Each detector emits findings with `kind`, `file`, `line`, `column`, and `matched_span` (the offending substring). The agent layer reads context around the line to judge.
+
+### KTD-5. Finding identity = file + line + kind + normalized-span
+
+The stable key for ledger preservation. Normalizing the matched span (lowercase + collapsed whitespace) tolerates minor formatting churn during agent fixes without invalidating accepts. The finding-ID is `sha1(file + ":" + line + ":" + kind + ":" + normalized-span)` truncated to 12 hex chars — same shape `tools-audit` uses for stable identity.
+
+When the operator edits a file (adding a comment, reformatting), line numbers shift and prior accepts no longer match. This is the expected behavior — significant file churn deserves a fresh agent review. Cosmetic-only edits (whitespace, comment additions) don't trigger re-scan because the regex pattern still matches and the matched span is normalized; only the line-number change forces a fresh ID.
+
+### KTD-6. Pre-decision fields enforce per-item deliberation on accepts
+
+Mirroring `tools-audit`'s `requiresPreDecisionFields` policy. Every `status: "accepted"` entry must populate:
+
+- `category`: one of `attribution` (the value is intentional author/maintainer attribution), `place_name` (geographic place, not a person), `corporate_name` (company/org, not a person), `documentation_example` (synthetic example data in docs/help text), `api_provider_data` (vendor-published example data from the API's own docs), `synthetic_placeholder` (already a placeholder like `EXAMPLE_*`), `other` (free-form, requires `note` to carry the rationale).
+- `evidence_context`: the surrounding 1-2 lines from the source file showing why the category applies. Empty `evidence_context` on an accept is a gate failure.
+
+The third optional field, `note`, is free-form rationale. Required when `category` is `other`.
+
+The pre-decision fields shift acceptance from "agent stamps 'false positive'" to "agent records the specific shape that explains why this isn't real PII." This is the load-bearing primitive against agent reflexive-accept.
+
+### KTD-7. Duplicate-rationale threshold = 5; delta-tracked numeric gate
+
+Mirroring `tools-audit`. If 6+ accepts share a normalized `note` (or the same `category` + `evidence_context` pattern), the run is incomplete. Differentiated rationales survive; bulk paste-the-same-thing-everywhere does not.
+
+Numeric end-state hedge: the binary tracks `findings_count_before` and `findings_count_now` between runs. If `findings_before - findings_now < code_fixes_applied`, the run is suspect — accepts dominated where source fixes should have. The audit emits a one-line delta summary on every run and a `gate_warnings` array in the ledger header.
+
+### KTD-8. Gate semantics: refuse on pending OR enforcement-primitive failure
+
+Promote and publish gates each invoke `pii-audit <target-dir>` directly and read the ledger header. Gate succeeds iff:
+
+1. Ledger contains zero findings with `status: ""` (pending).
+2. No `gate_warnings` from enforcement primitives (duplicate-rationale threshold breach, missing pre-decision fields on accepts, delta-tracking suspicion).
+3. Ledger is fresh (mtime within the staleness window, default 24h matching `tools-audit`).
+
+A pending finding is the failure state. An accepted finding with valid pre-decision fields is the cleared state. The gate doesn't read individual finding content — it reads the header summary and trusts the agent's per-item work.
+
+### KTD-9. No auto-mutation at the gate; agent makes fixes in source
+
+The agent walks the ledger during polish. For each finding the agent reads the surrounding context and either:
+
+- **Fixes in source** — replaces the real value with a placeholder (`PII_EMAIL_EXAMPLE`, `EXAMPLE_PHONE`, etc.) in the working dir directly. Next audit run won't detect anything at that line; finding auto-disappears from the ledger.
+- **Accepts with rationale** — edits the ledger entry to set `status: "accepted"` + the pre-decision fields above.
+
+This eliminates the silent-mutation and operator-bypass failure modes from the prior plan. The audit trail is the ledger itself, which lives in the CLI dir and is visible to PR reviewers.
+
+### KTD-10. No new dependencies — pure stdlib
+
+`regexp`, `bufio`, `os`, `io/fs`, `path/filepath`, `crypto/sha1`, `encoding/hex`, `encoding/json`. Mirrors both `secrets.go` and `tools_audit.go`'s stdlib posture.
+
+---
+
+## Implementation Units
+
+### U1. PII detector library
+
+**Goal:** Build `internal/artifacts/pii.go` exporting detection + ledger I/O helpers. No CLI wiring, no mutation logic.
+
+**Requirements:** R2 (file scoping), R3 (detector families), R6 (ledger identity).
+
+**Dependencies:** none.
+
+**Files:**
+- `internal/artifacts/pii.go` (new, ~220 LOC)
+- `internal/artifacts/pii_test.go` (new)
+- `internal/artifacts/testdata/pii/` (new — fixture files per detector + ledger scenarios)
+
+**Approach:**
+- Define `PIIFinding{Kind, File, Line, Column, MatchedSpan, Status, Note, Category, EvidenceContext}` struct. `Status`, `Note`, `Category`, `EvidenceContext` are `omitempty` (ledger-only, agent writes).
+- Define `piiDetector{kind, pattern *regexp.Regexp}` — no per-detector `accept` callback; high-precision regexes per KTD-4 carry their own context requirements.
+- Implement `FindPII(root string) ([]PIIFinding, error)`:
+ - `filepath.WalkDir` with the file-glob set from R2 (see helper `isHighRiskFile`).
+ - Per file: same binary-probe + line-oriented scan pattern as `secrets.go:127-172`. Skip binary files via null-byte probe.
+ - Per line: each detector's `FindAllStringIndex` produces (match, column) pairs.
+ - Sort findings by `(file, line, column, kind)` for stable output.
+- Implement finding-ID computation per KTD-5: `sha1(file + ":" + line + ":" + kind + ":" + normalizeSpan(matched))[:12]`. `normalizeSpan` is `strings.ToLower(strings.Join(strings.Fields(s), " "))`.
+- Implement ledger I/O mirroring `tools_audit.go:118-123`:
+ - `ReadPIILedger(cliDir string) (*PIILedger, error)` — reads `<cliDir>/.printing-press-pii-polish.json`, returns nil on missing file.
+ - `WritePIILedger(cliDir string, ledger *PIILedger) error` — atomic write via temp file + rename.
+ - `ReconcilePIILedger(previous *PIILedger, findings []PIIFinding) *PIILedger` — merges agent state from previous run onto new findings by identity-key.
+- Ledger header fields: `findings_count_before` (sticky), `last_audit_at`, `gate_warnings []string`, `progress.last_processed_finding_id`.
+- Enforcement primitive helpers:
+ - `validateAcceptedFindings(findings []PIIFinding) []string` — returns gate-warning strings for missing pre-decision fields, empty `evidence_context`, missing `category`.
+ - `validateDuplicateRationales(findings []PIIFinding, threshold int) []string` — clusters by normalized note + category, flags clusters above threshold.
+ - `validateDeltaProgress(before, now int, fixesApplied int) []string` — hedges agent-bypass via delta math.
+
+**Patterns to follow:**
+- [`internal/artifacts/secrets.go`](../../internal/artifacts/secrets.go) for walker + binary-probe + line-scan shape.
+- [`internal/cli/tools_audit.go:118-167`](../../internal/cli/tools_audit.go#L118) for ledger struct + reconcile + write semantics.
+
+**Test scenarios:**
+
+*Detector behavior (one fixture file per kind under `testdata/pii/`):*
+- Card last-4: `"card ending in 1234"` → flagged; `"* 1234 markdown bullet"` → NOT flagged (no context token).
+- Email: `"user@example.com"` → flagged; `"x@y"` (no TLD) → NOT flagged.
+- Phone: `"(415) 555-0123"` → flagged; `"version 1.2.3"` → NOT flagged.
+- ZIP+4: `"62701-1234"` → flagged. Agent layer judges if it's real PII; the detector emits unconditionally.
+- Postal-address: `"1234 MAIN STREET"` → flagged; `"SEE README.MD"` → NOT flagged.
+
+*File scoping:*
+- Plant identical patterns in a `.go` (non-test), a `_test.go`, a `.json`, and a `.md` file. Audit emits findings only from `_test.go`, `.json`, `.md`.
+- Binary file (planted PNG with embedded text) → skipped via null-byte probe.
+
+*Finding-ID stability:*
+- Same matched span at same file + line + kind across two `FindPII` calls → identical 12-char ID.
+- Whitespace-only change to the matched span → identical ID (normalization).
+- Same span at different line number → different ID.
+- Different span at same line → different ID.
+
+*Ledger I/O:*
+- `ReadPIILedger` on missing file → nil + nil error.
+- `ReconcilePIILedger`: prior finding with `status: "accepted"` whose ID matches current finding → status preserved on output. Prior accepted whose ID doesn't match → dropped (finding was fixed in source). Current pending without prior → appears fresh.
+- `WritePIILedger` atomic via temp + rename: simulated mid-write crash leaves the previous ledger intact.
+
+*Enforcement primitives:*
+- `validateAcceptedFindings`: accept with missing `category` → returns gate-warning string. Accept with empty `evidence_context` → returns gate-warning string.
+- `validateDuplicateRationales`: 5 accepts with the same normalized note → no warning. 6 accepts → returns gate-warning. Different `category` values with same note → not clustered.
+- `validateDeltaProgress`: before=10, now=10, fixes=0 → no warning (no change). before=10, now=5, fixes=5 → no warning (all reductions explained). before=10, now=5, fixes=0 → warning (accepts cleared findings that should have been fixed).
+
+**Verification:** `go test ./internal/artifacts/...` passes. `go vet ./...` clean.
+
+---
+
+### U2. `printing-press pii-audit` subcommand
+
+**Goal:** New CLI subcommand that runs `FindPII`, reconciles against the existing ledger, writes the new ledger, and renders a human-readable table (or JSON via `--json`). Exit 0 regardless of findings (diagnostic, not gating — gates check the ledger separately).
+
+**Requirements:** R1, R6, R7.
+
+**Dependencies:** U1.
+
+**Files:**
+- `internal/cli/pii_audit.go` (new, ~150 LOC) — mirrors `tools_audit.go` structure but slimmer.
+- `internal/cli/pii_audit_test.go` (new) — table-driven tests for the renderer + exit semantics.
+- `internal/cli/root.go` (modify — register `newPIIAuditCmd()`).
+
+**Approach:**
+- Define `newPIIAuditCmd()` cobra command. Single positional arg: `<cli-dir>`. `--json` flag for machine-readable output.
+- Flow mirrors `tools_audit.go:99-127`:
+ 1. Read previous ledger (`ReadPIILedger`).
+ 2. Run `FindPII(cliDir)`.
+ 3. Reconcile findings with previous ledger.
+ 4. Write the new ledger.
+ 5. Evaluate enforcement primitives → collect gate warnings.
+ 6. Render to stdout: human table with `next:` pointer + delta summary, OR JSON when `--json`.
+- Command should carry `Annotations: map[string]string{"mcp:read-only": "true"}` since it reads files and writes a status ledger.
+- Add `--strict` flag (default false) that exits non-zero when pending findings or gate warnings exist. Used by gates (U3, U4) to detect failure without re-parsing the table. Default remains zero so polish-flow integration matches `tools-audit` posture.
+
+**Patterns to follow:** [`internal/cli/tools_audit.go:81-132`](../../internal/cli/tools_audit.go#L81) for command shape + render + ledger flow. [`internal/cli/public_param_audit.go`](../../internal/cli/public_param_audit.go) for a slimmer reference.
+
+**Test scenarios:**
+- Run against a fixture CLI dir with planted real-shaped PII in `.manuscripts/foo.json` → audit emits findings; ledger persists; exit 0.
+- Run with `--json` → emits valid JSON array; ledger still persisted.
+- Run with `--strict` against fixture with pending findings → exit non-zero.
+- Run with `--strict` against fixture with all-accepted findings (pre-decision fields populated) → exit 0.
+- Run with `--strict` against fixture with all-accepted findings but missing `category` on one → exit non-zero (gate warning).
+- Run twice in succession: second run preserves agent-written accept fields when finding IDs match.
+- Run against a clean OpenAPI-spec CLI dir → zero findings; ledger header notes `findings_count_before: 0`.
+
+**Verification:** `go test ./internal/cli/... -run PIIAudit` passes. Smoke: `./printing-press pii-audit ./internal/artifacts/testdata/pii-fixture-cli` produces expected table.
+
+---
+
+### U3. Wire PII gate into promote flow
+
+**Goal:** `pipeline.PromoteWorkingCLI` invokes the audit against the working dir before staging and refuses if the audit's strict-mode exit is non-zero.
+
+**Requirements:** R1.
+
+**Dependencies:** U2.
+
+**Files:**
+- `internal/pipeline/lock.go` (modify — add audit invocation between line 231 [`validatePhase5GateForPromote`] and line 259 [`CopyDir`]).
+- `internal/pipeline/lock_test.go` (modify — extend existing `TestPromoteWorkingCLI*` tests to assert PII gate behavior; add planted-PII fixtures).
+
+**Approach:**
+- After `validatePhase5GateForPromote(workingDir, state)` at line 229, before the staging clean at line 255:
+ - Invoke `pii-audit <workingDir> --strict` via `exec.Command` against the same binary that's running. This mirrors how `printing-press-polish` already invokes the binary's own subcommands.
+ - On non-zero exit: return wrapped error pointing operators to the ledger file (`<workingDir>/.printing-press-pii-polish.json`) and to the polish skill's pii-polish playbook.
+ - On zero exit: continue to `CopyDir` and the existing flow.
+- The audit writes its ledger into `workingDir`. The subsequent `CopyDir` carries the ledger into staging — that's intentional. Reviewers seeing the published CLI can read the ledger to understand which findings were accepted and why.
+- No signature change to `PromoteWorkingCLI`. No new flag. The flow is: agent runs polish (which includes the pii-polish playbook), polish clears the ledger, promote sees a clean ledger and proceeds.
+
+**Patterns to follow:** `validatePhase5GateForPromote` pre-check pattern at [`internal/pipeline/lock.go:229`](../../internal/pipeline/lock.go#L229).
+
+**Test scenarios:**
+- Promote a working dir with planted real-shaped PII and no prior ledger → halts; staging dir not created; error message references ledger path.
+- Promote a working dir whose ledger has all findings accepted with valid pre-decision fields → succeeds; staging carries the ledger forward.
+- Promote a working dir whose ledger has 6+ accepts with identical rationale → halts (enforcement primitive); error references the duplicate-rationale gate warning.
+- Promote a working dir with no PII at all → audit emits no findings; succeeds; ledger is created with `findings_count: 0`.
+- Promote a working dir whose ledger is older than the 24h staleness window → audit re-runs (refreshes timestamp), then gate proceeds normally.
+
+**Verification:** `go test ./internal/pipeline/... -run Promote` passes. End-to-end smoke: plant a real-shape email in `<working-dir>/.manuscripts/foo.json`, run `printing-press lock promote --cli foo-pp-cli --dir <path>` → halt with error referencing ledger.
+
+---
+
+### U4. Wire PII gate into publish flow
+
+**Goal:** Run `pii-audit` against the staged outCLIDir at publish, alongside the existing vendor-prefix secret scan. Both must clear independently.
+
+**Requirements:** R8.
+
+**Dependencies:** U2.
+
+**Files:**
+- `internal/cli/publish.go` (modify — add audit invocation after the existing `FindVendorPrefixSecrets` call at line 382).
+- `internal/cli/publish_test.go` (modify — add publish-with-PII test scenarios).
+
+**Approach:**
+- After the existing secret-scan block (line 382-393), run `pii-audit <outCLIDir> --strict`.
+- On non-zero exit: combine with any preceding secret-scan findings into a single error message with clear section headers ("Vendor-prefix tokens" + "Customer PII") and call `cleanupOnFailure()` to remove the staged output.
+- On zero exit: continue with the existing manuscripts copy + success path.
+- The audit scans the manuscripts directory by default (R2 includes `**/.manuscripts/**`). This is intentional — manuscripts are the highest-risk PII source.
+
+**Patterns to follow:** Existing secret-scan call at [`internal/cli/publish.go:382-393`](../../internal/cli/publish.go#L382) — same `cleanupOnFailure()` + `ExitError{Code: ExitPublishError}` shape.
+
+**Test scenarios:**
+- Publish a staged dir with PII only (no secrets) → halts; combined error has only "Customer PII" section; staged output cleaned up.
+- Publish with secrets only → existing behavior; only "Vendor-prefix tokens" section.
+- Publish with both → halts; combined report has both sections; cleanup ran.
+- Publish a clean OpenAPI-spec CLI → both scans pass; existing success path unchanged. Covers R4.
+- Publish a staged dir whose `.manuscripts/<runID>/` has planted PII → halts (verifies manuscripts are in scan scope per KTD-2).
+
+**Verification:** `go test ./internal/cli/... -run Publish` passes.
+
+---
+
+### U5. Agent playbook for PII polish
+
+**Goal:** New reference document `skills/printing-press-polish/references/pii-polish.md` that walks the agent through the PII ledger — fix in source vs accept with pre-decision fields.
+
+**Requirements:** R6, R7.
+
+**Dependencies:** U2.
+
+**Files:**
+- `skills/printing-press-polish/references/pii-polish.md` (new, ~250 lines following the `tools-polish.md` shape).
+- `skills/printing-press-polish/SKILL.md` (modify — add a step that invokes `pii-audit` and routes the agent through `pii-polish.md` between the existing tools-polish step and the verify step).
+
+**Approach:** Document the playbook with this structure (matching `tools-polish.md`):
+- **Run the audit** — exact command, expected output shape.
+- **Per-finding decision tree** — for each detector kind:
+ - **Card last-4:** if it looks like a real card number, replace in source with `PII_CARD_LAST4_EXAMPLE`. If it's a documentation example or test fixture for masking display, accept with `category: documentation_example`.
+ - **Email:** if a real address, replace with `user@example.com`. If a public support address (`support@vendor.com` documented by the vendor), accept with `category: api_provider_data` + `evidence_context` quoting the vendor's published docs reference.
+ - **Phone:** if real, replace with `+1-555-0100` (RFC 3966 fictional). If a vendor support line, accept with `category: api_provider_data`.
+ - **ZIP+4:** if part of a real address, replace with `90210-1234`. If a request-ID or batch identifier, accept with `category: other` + note explaining the API field name.
+ - **Postal-address line:** if real, replace with `1 EXAMPLE WAY` or similar fictional address. If a documentation/help-text example, accept with `category: documentation_example`.
+- **Pre-decision fields** — what each `category` value means and what evidence justifies it.
+- **Duplicate rationale rule** — threshold 5; if you find yourself stamping the same note across many findings, you're punting; differentiate or fix in source.
+- **DO-NOT-EDIT files** — generated files (carrying the `Generated by CLI Printing Press` header) need a retro filing, not an inline edit. Same pattern as `tools-polish.md`'s DO-NOT-EDIT section.
+- **Manuscripts policy** — captured browser-sniff content is the highest-risk PII source. Fix in source means editing the manuscript file directly with a synthetic placeholder. Acceptance for manuscripts requires explicit `evidence_context` showing the value is vendor-documented or a synthetic placeholder.
+- **End-state checklist** — `printing-press pii-audit <cli-dir>` returns zero pending; ledger header `gate_warnings` is empty; delta math holds.
+
+**Patterns to follow:** [`skills/printing-press-polish/references/tools-polish.md`](../../skills/printing-press-polish/references/tools-polish.md) — exact section ordering, decision-tree shape, and DO-NOT-EDIT handling.
+
+**Test scenarios:** N/A (markdown reference doc).
+
+**Verification:** Manual review by the implementer. The doc is consumed by the polish skill in subsequent runs — its correctness surfaces through agent-driven polish runs against real CLIs, not via unit tests.
+
+---
+
+### U6. Polish skill integration
+
+**Goal:** Wire `pii-polish.md` into the polish skill's main flow so every polish run includes the PII pass.
+
+**Requirements:** R6.
+
+**Dependencies:** U5.
+
+**Files:**
+- `skills/printing-press-polish/SKILL.md` (modify — add a step block that points at `pii-polish.md` between tools-polish and the verify/promote-prep step).
+
+**Approach:**
+- Add a step to the polish skill's ordered playbook: after `tools-polish` completes, before any verify/promote step, the agent invokes `pii-audit` and walks `pii-polish.md`. The skill block names the reference file and notes the end-state expectation ("zero pending findings, zero gate warnings").
+- The skill block should be terse — the detailed instructions live in the reference file, mirroring how `tools-polish.md` is referenced.
+
+**Patterns to follow:** Whatever pattern SKILL.md already uses to reference `tools-polish.md` — copy the same shape.
+
+**Test scenarios:** N/A (skill markdown).
+
+**Verification:** Read SKILL.md after the edit to confirm the new step lands in the correct sequence and references the new file.
+
+---
+
+### U7. Golden-fixture review
+
+**Goal:** Confirm no existing golden fixtures contain PII shapes the new detectors flag. If any do, sanitize.
+
+**Requirements:** R4 (clean spec passes both gates).
+
+**Dependencies:** U1.
+
+**Files:**
+- `testdata/golden/expected/**` (read-only audit; modify only on real findings).
+- `scripts/golden.sh verify` output.
+
+**Approach:**
+- Run `FindPII` against `testdata/golden/expected/` after U1 lands.
+- For each finding: either a real shape needing replacement (sanitize with `PII_*_EXAMPLE` placeholder, update goldens, document the diff in the PR per AGENTS.md golden rule), or a fixture that's intentionally synthetic but matches the regex (e.g., `1234 EXAMPLE STREET`) — adjust the regex or accept as a fixture-level constant.
+
+**Test scenarios:** N/A — verification unit. Test expectation: `scripts/golden.sh verify` passes after any fixture updates this unit makes.
+
+**Verification:** `scripts/golden.sh verify` green; PR description names any fixtures touched and why.
+
+---
+
+## Scope Boundaries
+
+### In scope
+- Five detector families: card last-4 with context tokens, email, US phone, ZIP+4, postal-address line.
+- File scoping to high-risk paths (R2).
+- Ledger + enforcement primitives (pre-decision fields, duplicate-rationale, delta math).
+- Promote and publish gate wiring.
+- Polish skill integration via `pii-polish.md`.
+
+### Deferred to Follow-Up Work
+- **Person-name detection.** Regex precision is unrecoverable; agent walk-through volume is disproportionate. Needs NER (Presidio Go bridge or equivalent). File a follow-up issue when prioritized.
+- **Broader file scope** (non-test Go source, generated MCP shims). If a real leak surfaces in those file types, expand the glob set; until then, keep the false-positive surface narrow.
+- **Refactor of `secrets.go` and `pii.go` to share scaffold.** Worth doing once a third audit kind appears.
+- **JSON-aware parsing** for multi-line pretty-printed values. Current line-oriented scanner misses keys split from values across lines. If real captures use this format, expand to a tokenizer.
+
+### Outside this work's identity (tracked elsewhere)
+- **Spec-aware detectors** — order/transaction ID shapes from `auth.id_shapes`, cookie name+value from `auth.cookies`, ASIN-style entity IDs. **Tracked by [#960](https://github.com/mvanhorn/cli-printing-press/issues/960).**
+- **Browser-sniff capture sanitization** (preventing PII from being captured in the first place). Adjacent retro work unit.
+- **Test-fixture authoring guidance** — **Tracked by WU-3 in [#955](https://github.com/mvanhorn/cli-printing-press/issues/955).** The structural fix that complements the gate.
+- **Retroactive library audit** — re-scanning already-published CLIs. Separate one-shot operation; not part of this gate.
+
+---
+
+## System-Wide Impact
+
+- **Generated CLI behavior:** no change. The new gates run on the printing-press binary's promote/publish paths, not in printed-CLI runtime code.
+- **Existing published CLIs:** unaffected until next regen → polish → promote cycle.
+- **Polish workflow:** adds one new pass between tools-polish and verify. Per-CLI agent walk-through cost depends on finding count — expected 5-30 findings per browser-sniff CLI, 0-3 for OpenAPI-spec CLIs.
+- **CI:** publish-time audit adds a few hundred ms walk over staged dir.
+- **Operator UX:** new step in the polish skill; agents follow the playbook. No new CLI flags.
+
+---
+
+## Risks
+
+| Risk | Likelihood | Impact | Mitigation |
+|---|---|---|---|
+| Postal-address regex false positives | Medium | Agent walk-through noise | Context-token requirement (street suffix) in KTD-4; agent layer judges per-item; tighten in U1 against fixtures |
+| Detector misses real PII the parent retro found | Medium | The leak this WU exists to prevent | Phase-1 is shape-only; #960 covers spec-aware shapes; WU-3 prevents capture-into-fixture |
+| Agent stamps every finding `accepted` with the same note | Medium | Bypass of the gate | Enforcement primitives: pre-decision fields, duplicate-rationale threshold = 5, delta math (KTD-7) |
+| Finding-ID instability when operators edit files | Low | Re-walk needed | Line-number shift forces fresh ID by design; cosmetic edits don't shift line numbers and normalization tolerates whitespace |
+| `pii-audit` subcommand wires into MCP and gets called by agents at unexpected times | Low | Confusion in audit semantics | `mcp:read-only` annotation; audit exit 0 default (only `--strict` exits non-zero); ledger writes are idempotent |
+| Golden fixtures contain shapes the new detectors flag | Medium | U7 fails until sanitized | U7 explicitly audits; documented per AGENTS.md golden-update rule |
+| Person-name leaks slip through Phase-1 entirely | Medium | Names alone (no adjacent address) leak | Acknowledged trade-off; KTD-3 documents the deferral; address-proximity caught the leak in #955 |
+
+---
+
+## Verification Strategy
+
+- Unit tests per implementation unit (table-driven, `testify/assert` per AGENTS.md).
+- Integration smoke at end of U3 and U4: end-to-end promote/publish against a working dir with planted PII.
+- `scripts/golden.sh verify` after U7.
+- Manual smoke: replay the amazon-orders-style scenario (planted card last-4 + recipient address + ZIP+4) through `printing-press lock promote` → confirm halt with ledger pointer.
+- `go test ./...` clean before PR.
+- `go vet ./...` and `golangci-lint run ./...` clean.
+
+---
+
+## Notes for the implementer
+
+- **AGENTS.md "machine vs printed CLI":** machine change. New gates affect every future printed CLI. No printed-CLI template changes.
+- **AGENTS.md "Deterministic Inventory + Agent-Marked Ledger" pattern:** the entire design comes from [`docs/PATTERNS.md`](../PATTERNS.md). Read both that pattern and `tools_audit.go` + `tools-polish.md` before coding — they're the closest reference implementations.
+- **AGENTS.md "Code & Comment Hygiene":** issue references in PR/commits, not in source.
+- **Commit scope:** `cli` (Go binary) for U1-U4, U7; `skills` for U5, U6.
+- **Commit type:** `feat` (new subcommand, new gates, new skill content).
+- **Test-first execution posture is appropriate here** — the binary side has clean unit-test seams (detector + ledger I/O + enforcement primitives are all pure functions). Write the tests for U1's enforcement primitives before the implementation; the test scenarios in U1 are specific enough to drive TDD.
+- **Issue claim is already posted on #958.**
diff --git a/internal/artifacts/pii.go b/internal/artifacts/pii.go
new file mode 100644
index 00000000..5ec73586
--- /dev/null
+++ b/internal/artifacts/pii.go
@@ -0,0 +1,800 @@
+package artifacts
+
+import (
+ "bufio"
+ "bytes"
+ "crypto/sha1"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "regexp"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+)
+
+// PII gate implementation following the Deterministic Inventory +
+// Agent-Marked Ledger pattern (docs/PATTERNS.md). Peer to secrets.go.
+
+const (
+ PIILedgerFilename = ".printing-press-pii-polish.json"
+ PIILedgerStaleAfter = 24 * time.Hour
+ PIIStatusAccepted = "accepted"
+
+ // Caps for the punt-pattern gates. The 5 cluster cap matches
+ // tools-audit; the 10 baseline floor for all-accepted-no-fixes is
+ // large enough that small genuinely-clean runs (which legitimately
+ // accept everything) don't trip it.
+ PIIDuplicateRationaleThreshold = 5
+ PIIAllAcceptedNoFixesThreshold = 10
+)
+
+// Finding kinds. These appear in the JSON output and the ledger;
+// changing a value is a backward-incompatible ledger format change.
+const (
+ PIIKindCardLast4 = "card-last-4"
+ PIIKindEmail = "email"
+ PIIKindPhoneUS = "phone-us"
+ PIIKindZipPlus4 = "zip-plus-4"
+ PIIKindPostalAddress = "postal-address"
+)
+
+// Categories the agent picks from when accepting a finding. The closed
+// enum forces the agent to name the shape of non-PII; freeform reasoning
+// goes in Note.
+const (
+ PIICategoryAttribution = "attribution"
+ PIICategoryPlaceName = "place_name"
+ PIICategoryCorporateName = "corporate_name"
+ PIICategoryDocumentationExample = "documentation_example"
+ PIICategoryAPIProviderData = "api_provider_data"
+ PIICategorySyntheticPlaceholder = "synthetic_placeholder"
+ PIICategoryOther = "other"
+)
+
+// validPIICategories is the closed set the gate validates against.
+var validPIICategories = map[string]bool{
+ PIICategoryAttribution: true,
+ PIICategoryPlaceName: true,
+ PIICategoryCorporateName: true,
+ PIICategoryDocumentationExample: true,
+ PIICategoryAPIProviderData: true,
+ PIICategorySyntheticPlaceholder: true,
+ PIICategoryOther: true,
+}
+
+// PIIFinding is one mechanical detection. Status/Note/Category/
+// EvidenceContext are agent-written ledger fields preserved across
+// re-runs when the identity key (file, line, kind, normalized span)
+// matches.
+type PIIFinding struct {
+ Kind string `json:"kind"`
+ File string `json:"file"`
+ Line int `json:"line"`
+ Column int `json:"column"`
+ MatchedSpan string `json:"matched_span"`
+
+ Status string `json:"status,omitempty"`
+ Note string `json:"note,omitempty"`
+ Category string `json:"category,omitempty"`
+ EvidenceContext string `json:"evidence_context,omitempty"`
+}
+
+type piiDetector struct {
+ kind string
+ pattern *regexp.Regexp
+}
+
+var piiDetectors = []piiDetector{
+ {
+ kind: PIIKindCardLast4,
+ // Requires a context token within 5 characters of a 4-digit
+ // run. The alphabetic context tokens carry a leading word
+ // boundary so "discard 1234" doesn't match as "card 1234";
+ // mask shapes (xxxx, ****) are non-word so they can't carry
+ // \b but their length and shape are unambiguous.
+ pattern: regexp.MustCompile(`(?i)(?:\b(?:card|visa|mastercard|amex|ending in|last\s+4)|x{4,}|\*{4,})[\s:.\-]{0,5}\d{4}`),
+ },
+ {
+ kind: PIIKindEmail,
+ // Standard email shape with a TLD of 2+ chars. Word boundaries
+ // guard against capturing surrounding punctuation.
+ pattern: regexp.MustCompile(`\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b`),
+ },
+ {
+ kind: PIIKindPhoneUS,
+ // US-shaped 10-digit phone with optional +1 prefix and
+ // parens/separators. Word boundaries on each end. Catches
+ // "(415) 555-0123", "415-555-0123", "+1 415 555 0123".
+ pattern: regexp.MustCompile(`\b(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}\b`),
+ },
+ {
+ kind: PIIKindZipPlus4,
+ // Standard ZIP+4. Common false positive: API request IDs and
+ // batch identifiers with the same shape. Agent layer judges.
+ pattern: regexp.MustCompile(`\b\d{5}\-\d{4}\b`),
+ },
+ {
+ kind: PIIKindPostalAddress,
+ // Street-number + 1-4 name words (Title-Case OR ALL-CAPS) +
+ // street-suffix (case-insensitive). The Title-Case alternative
+ // catches real API responses (`1234 Main Street` is the default
+ // shape from Amazon/Shopify/Stripe/FedEx). Name words must
+ // start with an uppercase letter — this is the guard against
+ // conversational prose like "2 surfaces a clean way" where
+ // the words are all lowercase and would not match the leading
+ // `[A-Z]`. Fully-lowercase real addresses are still missed; if
+ // captures surface them, expand with explicit handling.
+ pattern: regexp.MustCompile(`\b\d+\s+[A-Z][a-zA-Z]+(?:\s+[A-Z][a-zA-Z]+){0,3}\s+(?i:ST|STREET|AVE|AVENUE|RD|ROAD|BLVD|BOULEVARD|DR|DRIVE|LN|LANE|CT|COURT|PL|PLACE|WAY)\b`),
+ },
+}
+
+// Scoped to the capture-to-publish leak path: manuscripts, test
+// fixtures, README, and *_test.go files. Non-test Go source and
+// module metadata are excluded by default.
+var highRiskFileGlobs = []string{
+ "*.json",
+ "*.yaml",
+ "*.yml",
+ "*.md",
+ "*_test.go",
+}
+
+var highRiskDirGlobs = []string{
+ ".manuscripts",
+ "testdata",
+}
+
+// ToolsPolishLedgerFilename is the tools-audit polish ledger basename.
+// Exported here (not in cli) to break the cli → artifacts cycle that
+// would otherwise force tools_audit.go to import its own package.
+const ToolsPolishLedgerFilename = ".printing-press-tools-polish.json"
+
+// Polish-skill ledgers contain matched_span of prior findings; without
+// the exclusion, the next audit recursively flags its own state.
+var excludedFiles = map[string]bool{
+ "tools-manifest.json": true,
+ PIILedgerFilename: true,
+ ToolsPolishLedgerFilename: true,
+}
+
+// skippedDirs are subtree names the walker never descends into at the
+// top level. Scoping to depth-1 is deliberate — `.git` and friends as
+// direct children of the cli-dir are infrastructure; the same names
+// nested inside `.manuscripts/` or `testdata/` are captured content
+// and must be scanned.
+var skippedDirs = map[string]bool{
+ ".git": true,
+ "node_modules": true,
+ "vendor": true,
+ "build": true,
+}
+
+// FindPII walks root, applies the file-scoping rules, and returns all
+// PII-shape matches. Ordering is stable (file, line, column, kind) so
+// the JSON output and ledger reconcile cleanly across runs.
+//
+// Per-file scan errors (unreadable file, permission denied) are logged
+// to stderr and skipped — a single bad file does not abort the gate.
+func FindPII(root string) ([]PIIFinding, error) {
+ var findings []PIIFinding
+ err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
+ if walkErr != nil {
+ return walkErr
+ }
+ if entry.IsDir() {
+ if path == root {
+ return nil
+ }
+ // Skip only at depth-1 from root.
+ parent := filepath.Dir(path)
+ if parent == root && skippedDirs[entry.Name()] {
+ return fs.SkipDir
+ }
+ return nil
+ }
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return err
+ }
+ relSlash := filepath.ToSlash(rel)
+ if !isHighRiskFile(relSlash) {
+ return nil
+ }
+ fileFindings, err := scanPIIFile(root, path)
+ if err != nil {
+ fmt.Fprintf(os.Stderr, "warning: pii-audit skipping %s: %v\n", relSlash, err)
+ return nil
+ }
+ findings = append(findings, fileFindings...)
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ sort.Slice(findings, func(i, j int) bool {
+ if findings[i].File != findings[j].File {
+ return findings[i].File < findings[j].File
+ }
+ if findings[i].Line != findings[j].Line {
+ return findings[i].Line < findings[j].Line
+ }
+ if findings[i].Column != findings[j].Column {
+ return findings[i].Column < findings[j].Column
+ }
+ return findings[i].Kind < findings[j].Kind
+ })
+ return findings, nil
+}
+
+// isHighRiskFile reports whether the relative path (forward-slash) is
+// in the scan scope. A file qualifies when its basename matches one
+// of the file globs OR when any path component matches one of the
+// directory globs. excludedFiles overrides matches.
+func isHighRiskFile(relSlash string) bool {
+ base := filepath.Base(relSlash)
+ if excludedFiles[base] {
+ return false
+ }
+ parts := strings.Split(relSlash, "/")
+ for _, dir := range highRiskDirGlobs {
+ if slices.Contains(parts, dir) {
+ return true
+ }
+ }
+ for _, pattern := range highRiskFileGlobs {
+ match, err := filepath.Match(pattern, base)
+ if err == nil && match {
+ return true
+ }
+ }
+ // README is always in scope (any case, with or without extension
+ // handled by *.md above; this catches bare README files).
+ if strings.EqualFold(base, "README") {
+ return true
+ }
+ return false
+}
+
+// scanPIIFile runs all detectors against a single file line-by-line.
+// Binary files (null-byte probe) are skipped. Returns findings keyed
+// to the file's path relative to root with forward-slash separators.
+func scanPIIFile(root, path string) ([]PIIFinding, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = file.Close() }()
+
+ reader := bufio.NewReaderSize(file, 8192)
+ probe, err := reader.Peek(8192)
+ if err != nil && err != io.EOF && err != bufio.ErrBufferFull {
+ return nil, err
+ }
+ if bytes.Contains(probe, []byte{0}) {
+ return nil, nil
+ }
+
+ rel, err := filepath.Rel(root, path)
+ if err != nil {
+ return nil, err
+ }
+ relSlash := filepath.ToSlash(rel)
+
+ var findings []PIIFinding
+ lineNumber := 0
+ for {
+ line, readErr := reader.ReadString('\n')
+ if readErr != nil && readErr != io.EOF {
+ return nil, readErr
+ }
+ if line == "" && readErr == io.EOF {
+ break
+ }
+ lineNumber++
+ for _, det := range piiDetectors {
+ for _, match := range det.pattern.FindAllStringIndex(line, -1) {
+ findings = append(findings, PIIFinding{
+ Kind: det.kind,
+ File: relSlash,
+ Line: lineNumber,
+ Column: match[0] + 1, // 1-based
+ MatchedSpan: line[match[0]:match[1]],
+ })
+ }
+ }
+ if readErr == io.EOF {
+ break
+ }
+ }
+ return findings, nil
+}
+
+// PIIFindingID returns a stable 12-hex-char identifier for the finding.
+// The hash composition is (file, line, kind, normalized matched span)
+// — normalization collapses internal whitespace and lowercases so
+// cosmetic formatting churn doesn't invalidate prior accepts. Line
+// changes still force a fresh ID by design.
+func PIIFindingID(f PIIFinding) string {
+ key := piiFindingKey(f)
+ sum := sha1.Sum([]byte(key))
+ return hex.EncodeToString(sum[:6])
+}
+
+// piiFindingKey is the input to the SHA-1 hash. Kept stable across the
+// codebase via this single helper.
+func piiFindingKey(f PIIFinding) string {
+ return fmt.Sprintf("%s\x00%d\x00%s\x00%s", f.File, f.Line, f.Kind, normalizePIISpan(f.MatchedSpan))
+}
+
+// normalizePIISpan lowercases and collapses internal whitespace.
+// Tolerates cosmetic edits to the matched span without invalidating
+// the finding ID — the actual character sequence (modulo whitespace
+// and case) is what identifies the match.
+func normalizePIISpan(s string) string {
+ return strings.ToLower(strings.Join(strings.Fields(s), " "))
+}
+
+// PIILedger is the on-disk snapshot of the last PII audit run.
+//
+// FindingsCountBefore is sticky: captured on the first audit run that
+// finds no existing ledger and preserved across subsequent runs. It
+// anchors the all-accepted-no-fixes gate.
+//
+// Progress is an optional checkpoint the agent writes after walking
+// each finding. A re-invocation after a context flush reads it to
+// resume mid-walk. When absent, the resume hint is the first pending
+// finding in scan order.
+type PIILedger struct {
+ Timestamp time.Time `json:"timestamp"`
+ CLIDir string `json:"cli_dir"`
+ Findings []PIIFinding `json:"findings"`
+ FindingsCountBefore int `json:"findings_count_before"`
+ Progress *PIIProgress `json:"progress,omitempty"`
+}
+
+// PIIProgress is the agent-written resume checkpoint.
+type PIIProgress struct {
+ LastProcessedFindingID string `json:"last_processed_finding_id,omitempty"`
+}
+
+type PIILedgerDelta struct {
+ HasPrevious bool
+ Resolved []PIIFinding // present in previous, absent in current (fixed in source)
+ Added []PIIFinding // present in current, absent in previous
+}
+
+// ReadPIILedger loads the ledger at <cliDir>/<PIILedgerFilename>.
+// Returns nil for missing files. Corrupt files are deleted (the data
+// is unrecoverable). Stale ledgers are returned with their content
+// intact — auto-deletion on staleness silently destroyed accumulated
+// agent accepts, so the caller checks IsStalePIILedger to emit a
+// warning rather than erasing the ledger.
+func ReadPIILedger(cliDir string) *PIILedger {
+ path := filepath.Join(cliDir, PIILedgerFilename)
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+ var l PIILedger
+ if err := json.Unmarshal(data, &l); err != nil {
+ _ = os.Remove(path)
+ return nil
+ }
+ return &l
+}
+
+// IsStalePIILedger reports whether a ledger's timestamp is older than
+// the staleness window. Callers (audit, gates) decide what to do —
+// typically warn but continue using the ledger so agent state is not
+// lost on a slow-moving workflow.
+func IsStalePIILedger(l *PIILedger) bool {
+ if l == nil {
+ return false
+ }
+ return time.Since(l.Timestamp) > PIILedgerStaleAfter
+}
+
+// WritePIILedger serializes the ledger and writes it atomically via
+// temp file + rename. A crash mid-write leaves the previous ledger
+// intact instead of producing a partial file that ReadPIILedger
+// would silently delete, losing accumulated agent state.
+func WritePIILedger(cliDir string, ledger *PIILedger) error {
+ data, err := json.MarshalIndent(ledger, "", " ")
+ if err != nil {
+ return fmt.Errorf("encoding PII ledger: %w", err)
+ }
+ data = append(data, '\n')
+
+ finalPath := filepath.Join(cliDir, PIILedgerFilename)
+ tmpFile, err := os.CreateTemp(cliDir, PIILedgerFilename+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("creating temp PII ledger: %w", err)
+ }
+ tmpPath := tmpFile.Name()
+ if _, err := tmpFile.Write(data); err != nil {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("writing temp PII ledger: %w", err)
+ }
+ if err := tmpFile.Sync(); err != nil {
+ _ = tmpFile.Close()
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("syncing temp PII ledger: %w", err)
+ }
+ if err := tmpFile.Close(); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("closing temp PII ledger: %w", err)
+ }
+ if err := os.Rename(tmpPath, finalPath); err != nil {
+ _ = os.Remove(tmpPath)
+ return fmt.Errorf("renaming PII ledger: %w", err)
+ }
+ return nil
+}
+
+// ReconcilePIILedger carries Status/Note/Category/EvidenceContext from
+// the previous ledger onto matching current findings and computes the
+// resolved/added delta in a single pass. Identity is the piiFindingKey
+// (file + line + kind + normalized-span); a finding whose matched
+// span was rewritten in source reads as "old resolved, new added"
+// rather than mutated.
+//
+// Mutates `current` in place to backfill agent fields.
+func ReconcilePIILedger(previous *PIILedger, current []PIIFinding) PIILedgerDelta {
+ if previous == nil {
+ return PIILedgerDelta{}
+ }
+ prev := make(map[string]PIIFinding, len(previous.Findings))
+ for _, f := range previous.Findings {
+ prev[piiFindingKey(f)] = f
+ }
+ delta := PIILedgerDelta{HasPrevious: true}
+ seen := make(map[string]bool, len(current))
+ for i := range current {
+ k := piiFindingKey(current[i])
+ seen[k] = true
+ if old, ok := prev[k]; ok {
+ current[i].Status = old.Status
+ current[i].Note = old.Note
+ current[i].Category = old.Category
+ current[i].EvidenceContext = old.EvidenceContext
+ } else {
+ delta.Added = append(delta.Added, current[i])
+ }
+ }
+ for k, f := range prev {
+ if !seen[k] {
+ delta.Resolved = append(delta.Resolved, f)
+ }
+ }
+ return delta
+}
+
+// Gate fields all empty + no pending findings means the run is complete.
+type PIICompletionStatus struct {
+ IncompleteAccepts []PIIFinding // accepts that fail missingPIIAcceptFields — see that helper for the full predicate
+ DuplicateRationaleGroups []PIIRationaleGroup // accepts sharing a normalized note
+ AllAcceptedNoFixes *PIIAllAcceptedIssue // every finding accepted, zero fixes from baseline
+ NextPending *PIIFinding // resume hint
+}
+
+type PIIRationaleGroup struct {
+ Rationale string
+ Findings []PIIFinding
+}
+
+type PIIAllAcceptedIssue struct {
+ Baseline int
+ Current int
+ Accepted int
+}
+
+func EvaluatePIICompletion(findings []PIIFinding, previous *PIILedger) PIICompletionStatus {
+ var c PIICompletionStatus
+ for _, f := range findings {
+ if f.Status != PIIStatusAccepted {
+ continue
+ }
+ if missingPIIAcceptFields(f) {
+ c.IncompleteAccepts = append(c.IncompleteAccepts, f)
+ }
+ }
+ c.DuplicateRationaleGroups = detectPIIDuplicateRationales(findings, PIIDuplicateRationaleThreshold)
+ c.AllAcceptedNoFixes = checkPIIAllAcceptedNoFixes(findings, previous)
+ c.NextPending = nextPIIPendingFinding(findings, previous)
+ return c
+}
+
+// missingPIIAcceptFields returns true when an accepted finding is
+// missing the required pre-decision fields. Either field can fail:
+// empty category, invalid category, or empty evidence_context.
+// Note (free-form) is optional unless category is "other".
+func missingPIIAcceptFields(f PIIFinding) bool {
+ if strings.TrimSpace(f.Category) == "" {
+ return true
+ }
+ if !validPIICategories[f.Category] {
+ return true
+ }
+ if strings.TrimSpace(f.EvidenceContext) == "" {
+ return true
+ }
+ if f.Category == PIICategoryOther && strings.TrimSpace(f.Note) == "" {
+ return true
+ }
+ return false
+}
+
+// detectPIIDuplicateRationales clusters accepts by (category, normalized
+// note). The category split prevents legitimate overlap (5 accepts as
+// place_name is fine; 6 with identical free-form note in other is the
+// punt pattern). Returns groups exceeding threshold.
+func detectPIIDuplicateRationales(findings []PIIFinding, threshold int) []PIIRationaleGroup {
+ if threshold <= 0 {
+ return nil
+ }
+ type rationaleKey struct{ category, note string }
+ clusters := make(map[rationaleKey][]PIIFinding)
+ for _, f := range findings {
+ if f.Status != PIIStatusAccepted {
+ continue
+ }
+ note := normalizePIISpan(f.Note)
+ if note == "" {
+ continue
+ }
+ clusters[rationaleKey{f.Category, note}] = append(clusters[rationaleKey{f.Category, note}], f)
+ }
+ var groups []PIIRationaleGroup
+ for _, fs := range clusters {
+ if len(fs) > threshold {
+ groups = append(groups, PIIRationaleGroup{
+ Rationale: fs[0].Note,
+ Findings: fs,
+ })
+ }
+ }
+ sort.Slice(groups, func(i, j int) bool {
+ return len(groups[i].Findings) > len(groups[j].Findings)
+ })
+ return groups
+}
+
+// checkPIIAllAcceptedNoFixes fires when the agent accepted every
+// finding without doing any source-level work, at a scale that
+// indicates a punt. Trigger condition:
+// - Current finding count is at or above the threshold (large
+// batches deserve scrutiny; small batches can legitimately be
+// accepted wholesale).
+// - Every finding is accepted (zero pending).
+// - Either no prior baseline exists, or the baseline matches the
+// current count (no findings were removed in source).
+//
+// This formulation closes the incremental-add bypass: previously, when
+// new findings appeared after the sticky baseline, the gate compared
+// current vs baseline (now unequal) and never fired. Now the test is
+// "did source-level fixes occur" — measured as current count below
+// baseline — which is the actual punt signal.
+func checkPIIAllAcceptedNoFixes(findings []PIIFinding, previous *PIILedger) *PIIAllAcceptedIssue {
+ current := len(findings)
+ if current < PIIAllAcceptedNoFixesThreshold {
+ return nil
+ }
+ accepted := 0
+ for _, f := range findings {
+ if f.Status == PIIStatusAccepted {
+ accepted++
+ }
+ }
+ if accepted != current {
+ return nil
+ }
+ baseline := 0
+ if previous != nil {
+ baseline = previous.FindingsCountBefore
+ }
+ // If baseline exists and current is below it, the agent removed
+ // findings in source (legitimate work). Gate does not fire.
+ if baseline > 0 && current < baseline {
+ return nil
+ }
+ return &PIIAllAcceptedIssue{
+ Baseline: baseline,
+ Current: current,
+ Accepted: accepted,
+ }
+}
+
+// isPIIPending reports whether a finding still needs agent attention.
+// Status unset → pending. Status accepted but missing pre-decision
+// fields → pending (incomplete accept).
+func isPIIPending(f PIIFinding) bool {
+ if f.Status != PIIStatusAccepted {
+ return true
+ }
+ return missingPIIAcceptFields(f)
+}
+
+// nextPIIPendingFinding returns the resume hint. The previous ledger's
+// Progress.LastProcessedFindingID is read as a soft hint: when present,
+// the search starts after that finding. Falls back to head-scan when
+// exhausted or absent.
+func nextPIIPendingFinding(findings []PIIFinding, previous *PIILedger) *PIIFinding {
+ startIdx := 0
+ if previous != nil && previous.Progress != nil && previous.Progress.LastProcessedFindingID != "" {
+ for i, f := range findings {
+ if PIIFindingID(f) == previous.Progress.LastProcessedFindingID {
+ startIdx = i + 1
+ break
+ }
+ }
+ }
+ if f := firstPIIPending(findings, startIdx, len(findings)); f != nil {
+ return f
+ }
+ return firstPIIPending(findings, 0, startIdx)
+}
+
+func firstPIIPending(findings []PIIFinding, lo, hi int) *PIIFinding {
+ for i := lo; i < hi; i++ {
+ if isPIIPending(findings[i]) {
+ f := findings[i]
+ return &f
+ }
+ }
+ return nil
+}
+
+// GateFailureCount returns the number of gates surfacing an issue the
+// agent must act on. NextPending is a resume hint, not a gate, and is
+// excluded.
+func (c PIICompletionStatus) GateFailureCount() int {
+ n := 0
+ if len(c.IncompleteAccepts) > 0 {
+ n++
+ }
+ if len(c.DuplicateRationaleGroups) > 0 {
+ n++
+ }
+ if c.AllAcceptedNoFixes != nil {
+ n++
+ }
+ return n
+}
+
+// HasGateFailure is the boolean form of GateFailureCount. Both are used:
+// HasGateFailure for branching, GateFailureCount for the integer in
+// error messages.
+func (c PIICompletionStatus) HasGateFailure() bool {
+ return c.GateFailureCount() > 0
+}
+
+type PIIAuditResult struct {
+ Findings []PIIFinding
+ Delta PIILedgerDelta
+ Completion PIICompletionStatus
+}
+
+// RunPIIAudit performs a full audit cycle against dir: scan with all
+// detectors, reconcile with prior ledger (carrying agent state forward),
+// write the new ledger, and evaluate enforcement primitives. Shared by
+// the pii-audit subcommand (non-JSON path) and the promote/publish gates.
+//
+// The ledger write is best-effort — if it fails (read-only directory,
+// disk full), the audit result is still returned and a warning is
+// logged to stderr. The gate decision uses the in-memory result.
+func RunPIIAudit(dir string) (PIIAuditResult, error) {
+ return runPIIAudit(dir, true)
+}
+
+// ScanPII performs the audit without writing the ledger. The pii-audit
+// CLI's --json path uses this so a read-only probe doesn't have the
+// side effect of touching the filesystem.
+func ScanPII(dir string) (PIIAuditResult, error) {
+ return runPIIAudit(dir, false)
+}
+
+func runPIIAudit(dir string, persist bool) (PIIAuditResult, error) {
+ findings, err := FindPII(dir)
+ if err != nil {
+ return PIIAuditResult{}, fmt.Errorf("scanning %s for PII: %w", dir, err)
+ }
+ previous := ReadPIILedger(dir)
+ delta := ReconcilePIILedger(previous, findings)
+
+ if persist {
+ ledger := &PIILedger{
+ Timestamp: time.Now().UTC(),
+ CLIDir: dir,
+ Findings: findings,
+ }
+ if previous != nil {
+ ledger.FindingsCountBefore = previous.FindingsCountBefore
+ } else {
+ ledger.FindingsCountBefore = len(findings)
+ }
+ if previous != nil && previous.Progress != nil {
+ ledger.Progress = previous.Progress
+ }
+ if writeErr := WritePIILedger(dir, ledger); writeErr != nil {
+ fmt.Fprintf(os.Stderr, "warning: writing PII ledger: %v\n", writeErr)
+ }
+ }
+
+ return PIIAuditResult{
+ Findings: findings,
+ Delta: delta,
+ Completion: EvaluatePIICompletion(findings, previous),
+ }, nil
+}
+
+func PIIPendingCount(findings []PIIFinding) int {
+ n := 0
+ for _, f := range findings {
+ if isPIIPending(f) {
+ n++
+ }
+ }
+ return n
+}
+
+func FormatPIIFindings(findings []PIIFinding) string {
+ var lines []string
+ for _, f := range findings {
+ if !isPIIPending(f) {
+ continue
+ }
+ lines = append(lines, fmt.Sprintf(" %s:%d %s [%s] %s",
+ f.File, f.Line, f.Kind, PIIFindingID(f), truncatePIIMatch(f.MatchedSpan, 60)))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func FormatPIIGateFailures(c PIICompletionStatus) string {
+ if !c.HasGateFailure() {
+ return ""
+ }
+ var lines []string
+ if len(c.IncompleteAccepts) > 0 {
+ lines = append(lines, fmt.Sprintf(" %d accepted finding(s) with missing or invalid pre-decision fields (category, evidence_context, or note when category=other):",
+ len(c.IncompleteAccepts)))
+ for _, f := range c.IncompleteAccepts {
+ lines = append(lines, fmt.Sprintf(" %s:%d %s [%s]",
+ f.File, f.Line, f.Kind, PIIFindingID(f)))
+ }
+ }
+ for _, g := range c.DuplicateRationaleGroups {
+ lines = append(lines, fmt.Sprintf(" %d accepted finding(s) share rationale %q — differentiate per item:",
+ len(g.Findings), truncatePIIMatch(g.Rationale, 60)))
+ for _, f := range g.Findings {
+ lines = append(lines, fmt.Sprintf(" %s:%d %s [%s]",
+ f.File, f.Line, f.Kind, PIIFindingID(f)))
+ }
+ }
+ if d := c.AllAcceptedNoFixes; d != nil {
+ lines = append(lines, fmt.Sprintf(" all %d finding(s) accepted with zero source fixes from baseline — agent stamped accept without fixing real PII",
+ d.Accepted))
+ }
+ return strings.Join(lines, "\n")
+}
+
+// truncatePIIMatch walks runes (not bytes) so multibyte characters in
+// matched spans (unicode email domains, addresses with diacritics) are
+// preserved at the boundary. Mirrors internal/cli/mcp_audit.go's
+// truncate; the package boundary blocks direct reuse.
+func truncatePIIMatch(s string, n int) string {
+ runes := []rune(s)
+ if len(runes) <= n {
+ return s
+ }
+ if n <= 1 {
+ return string(runes[:n])
+ }
+ return string(runes[:n-1]) + "…"
+}
diff --git a/internal/artifacts/pii_test.go b/internal/artifacts/pii_test.go
new file mode 100644
index 00000000..e623e9c5
--- /dev/null
+++ b/internal/artifacts/pii_test.go
@@ -0,0 +1,650 @@
+package artifacts
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// ---------------------------------------------------------------------------
+// Detector behavior
+// ---------------------------------------------------------------------------
+
+func TestFindPII_CardLast4(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "ending-in", line: `"display": "card ending in 4242"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "mask-asterisks", line: `"masked": "****-****-****-1234"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "mask-x", line: `"masked": "xxxx-xxxx-xxxx-9999"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "visa-context", line: `"brand": "visa 5678"`, expectKinds: []string{PIIKindCardLast4}},
+ {name: "no-context-bare-4digits", line: `* 1234 changelog bullet`, expectKinds: nil},
+ {name: "no-context-year", line: `"version": "2024"`, expectKinds: nil},
+ {name: "no-context-port", line: `"port": "8080"`, expectKinds: nil},
+ // Regression: "card" as a substring of a larger word must not match.
+ // The \b boundary in the regex is the guard.
+ {name: "no-substring-discard", line: `discard 1234`, expectKinds: nil},
+ {name: "no-substring-wildcard", line: `wildcard 9999`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindCardLast4)
+ })
+ }
+}
+
+func TestFindPII_Email(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "standard", line: `"email": "alice@example.com"`, expectKinds: []string{PIIKindEmail}},
+ {name: "plus-tag", line: `"email": "alice+tag@example.com"`, expectKinds: []string{PIIKindEmail}},
+ {name: "no-tld", line: `"handle": "alice@example"`, expectKinds: nil},
+ {name: "missing-at", line: `"site": "example.com"`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindEmail)
+ })
+ }
+}
+
+func TestFindPII_PhoneUS(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "parens-space-dash", line: `"phone": "(415) 555-0123"`, expectKinds: []string{PIIKindPhoneUS}},
+ {name: "all-dashes", line: `"phone": "415-555-0123"`, expectKinds: []string{PIIKindPhoneUS}},
+ {name: "country-code", line: `"phone": "+1 415 555 0123"`, expectKinds: []string{PIIKindPhoneUS}},
+ {name: "version-string", line: `"version": "1.2.3"`, expectKinds: nil},
+ {name: "ip-address", line: `"addr": "192.168.1.1"`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindPhoneUS)
+ })
+ }
+}
+
+func TestFindPII_ZipPlus4(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "standard", line: `"zip": "62701-1234"`, expectKinds: []string{PIIKindZipPlus4}},
+ {name: "five-only", line: `"zip": "62701"`, expectKinds: nil},
+ {name: "different-shape", line: `"id": "abc-12345"`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindZipPlus4)
+ })
+ }
+}
+
+func TestFindPII_PostalAddress(t *testing.T) {
+ tests := []struct {
+ name string
+ line string
+ expectKinds []string
+ }{
+ {name: "main-street-allcaps", line: `"address": "1234 MAIN STREET"`, expectKinds: []string{PIIKindPostalAddress}},
+ {name: "ave-allcaps", line: `"line1": "567 PARK AVE"`, expectKinds: []string{PIIKindPostalAddress}},
+ {name: "blvd-allcaps", line: `"line1": "890 SUNSET BLVD"`, expectKinds: []string{PIIKindPostalAddress}},
+ // Title-Case is the default API shape (Amazon/Shopify/Stripe/FedEx).
+ {name: "main-street-title", line: `"address": "1234 Main Street"`, expectKinds: []string{PIIKindPostalAddress}},
+ {name: "ave-title", line: `"line1": "567 Park Ave"`, expectKinds: []string{PIIKindPostalAddress}},
+ {name: "drive-lowercase-suffix", line: `"line1": "890 Sunset drive"`, expectKinds: []string{PIIKindPostalAddress}},
+ {name: "no-number", line: `"page": "SEE README.MD"`, expectKinds: nil},
+ {name: "no-suffix", line: `"line": "1234 MAIN"`, expectKinds: nil},
+ // Regression guards: conversational prose where the name words
+ // are lowercase must not match (the leading [A-Z] is the guard).
+ {name: "no-way-in-prose", line: `2 surfaces a clean way`, expectKinds: nil},
+ {name: "no-way-mixed-case", line: `1 changes generator behavior in a way`, expectKinds: nil},
+ // Fully-lowercase real address is the documented gap.
+ {name: "lowercase-real-address-missed", line: `"address": "1234 main street"`, expectKinds: nil},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := scanLine(t, tt.line, "test.json")
+ assertKinds(t, got, tt.expectKinds, PIIKindPostalAddress)
+ })
+ }
+}
+
+// ---------------------------------------------------------------------------
+// File scoping
+// ---------------------------------------------------------------------------
+
+func TestFindPII_FileScoping(t *testing.T) {
+ root := t.TempDir()
+ // Same PII shape planted in different file types
+ pii := `"email": "leak@example.com"`
+ write(t, filepath.Join(root, "in-scope.json"), pii)
+ write(t, filepath.Join(root, "in-scope.yaml"), pii)
+ write(t, filepath.Join(root, "in-scope.md"), pii)
+ write(t, filepath.Join(root, "in_scope_test.go"), pii)
+ write(t, filepath.Join(root, "out-of-scope.go"), pii)
+ write(t, filepath.Join(root, "out-of-scope.txt"), pii)
+ write(t, filepath.Join(root, "out-of-scope.lock"), pii)
+
+ findings, err := FindPII(root)
+ require.NoError(t, err)
+
+ files := uniqueFiles(findings)
+ assert.ElementsMatch(t, []string{
+ "in-scope.json", "in-scope.yaml", "in-scope.md", "in_scope_test.go",
+ }, files)
+}
+
+func TestFindPII_DirScoping(t *testing.T) {
+ root := t.TempDir()
+ pii := `"phone": "(415) 555-0123"`
+ // .manuscripts and testdata are in scope regardless of extension
+ require.NoError(t, os.MkdirAll(filepath.Join(root, ".manuscripts", "run1"), 0755))
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "testdata", "fixtures"), 0755))
+ require.NoError(t, os.MkdirAll(filepath.Join(root, "internal"), 0755))
+ write(t, filepath.Join(root, ".manuscripts", "run1", "raw.har"), pii)
+ write(t, filepath.Join(root, "testdata", "fixtures", "sample.txt"), pii)
+ write(t, filepath.Join(root, "internal", "client.go"), pii) // not in scope
+
+ findings, err := FindPII(root)
+ require.NoError(t, err)
+
+ files := uniqueFiles(findings)
+ assert.Contains(t, files, ".manuscripts/run1/raw.har")
+ assert.Contains(t, files, "testdata/fixtures/sample.txt")
+ assert.NotContains(t, files, "internal/client.go")
+}
+
+func TestFindPII_ExcludedFiles(t *testing.T) {
+ root := t.TempDir()
+ pii := `"email": "leak@example.com"`
+ write(t, filepath.Join(root, "tools-manifest.json"), pii)
+ write(t, filepath.Join(root, "data.json"), pii)
+
+ findings, err := FindPII(root)
+ require.NoError(t, err)
+
+ files := uniqueFiles(findings)
+ assert.NotContains(t, files, "tools-manifest.json")
+ assert.Contains(t, files, "data.json")
+}
+
+func TestFindPII_BinaryFileSkip(t *testing.T) {
+ root := t.TempDir()
+ // Planted PII in a "json" file with embedded nulls (mimics binary)
+ bin := []byte("\"email\": \"leak@example.com\"\x00\x00\x00binary content")
+ require.NoError(t, os.WriteFile(filepath.Join(root, "blob.json"), bin, 0644))
+
+ findings, err := FindPII(root)
+ require.NoError(t, err)
+ assert.Empty(t, findings)
+}
+
+func TestFindPII_StableOrder(t *testing.T) {
+ root := t.TempDir()
+ content := strings.Join([]string{
+ `"email": "alice@example.com"`, // line 1, kind email
+ `"address": "1234 MAIN STREET"`, // line 2, kind postal-address
+ `"phone": "(415) 555-0123"`, // line 3, kind phone-us
+ }, "\n")
+ write(t, filepath.Join(root, "data.json"), content)
+
+ findings, err := FindPII(root)
+ require.NoError(t, err)
+ require.Len(t, findings, 3)
+ assert.Equal(t, 1, findings[0].Line)
+ assert.Equal(t, 2, findings[1].Line)
+ assert.Equal(t, 3, findings[2].Line)
+}
+
+// ---------------------------------------------------------------------------
+// Finding-ID stability
+// ---------------------------------------------------------------------------
+
+func TestPIIFindingID_Stable(t *testing.T) {
+ f := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: `alice@example.com`}
+ id1 := PIIFindingID(f)
+ id2 := PIIFindingID(f)
+ assert.Equal(t, id1, id2)
+ assert.Len(t, id1, 12)
+}
+
+func TestPIIFindingID_NormalizationTolerance(t *testing.T) {
+ base := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: `ALICE@example.com`}
+ whitespace := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: `alice@example.com`}
+ assert.Equal(t, PIIFindingID(base), PIIFindingID(whitespace))
+}
+
+func TestPIIFindingID_LineSensitive(t *testing.T) {
+ a := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: `alice@example.com`}
+ b := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 6, MatchedSpan: `alice@example.com`}
+ assert.NotEqual(t, PIIFindingID(a), PIIFindingID(b))
+}
+
+func TestPIIFindingID_SpanSensitive(t *testing.T) {
+ a := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: `alice@example.com`}
+ b := PIIFinding{Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: `bob@example.com`}
+ assert.NotEqual(t, PIIFindingID(a), PIIFindingID(b))
+}
+
+// ---------------------------------------------------------------------------
+// Ledger I/O
+// ---------------------------------------------------------------------------
+
+func TestReadPIILedger_Missing(t *testing.T) {
+ dir := t.TempDir()
+ got := ReadPIILedger(dir)
+ assert.Nil(t, got)
+}
+
+func TestReadWritePIILedger_RoundTrip(t *testing.T) {
+ dir := t.TempDir()
+ original := &PIILedger{
+ Timestamp: time.Now().UTC(),
+ CLIDir: dir,
+ FindingsCountBefore: 3,
+ Findings: []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "alice@example.com"},
+ },
+ }
+ require.NoError(t, WritePIILedger(dir, original))
+
+ got := ReadPIILedger(dir)
+ require.NotNil(t, got)
+ assert.Equal(t, 3, got.FindingsCountBefore)
+ assert.Len(t, got.Findings, 1)
+}
+
+func TestReadPIILedger_CorruptDeletesFile(t *testing.T) {
+ dir := t.TempDir()
+ require.NoError(t, os.WriteFile(filepath.Join(dir, PIILedgerFilename), []byte("not json"), 0644))
+
+ got := ReadPIILedger(dir)
+ assert.Nil(t, got)
+ _, err := os.Stat(filepath.Join(dir, PIILedgerFilename))
+ assert.True(t, os.IsNotExist(err), "corrupt ledger should be deleted")
+}
+
+func TestReadPIILedger_StalePreservesContent(t *testing.T) {
+ dir := t.TempDir()
+ stale := &PIILedger{
+ Timestamp: time.Now().Add(-2 * PIILedgerStaleAfter).UTC(),
+ CLIDir: dir,
+ FindingsCountBefore: 7,
+ Findings: []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 1, MatchedSpan: "x@y.z", Status: PIIStatusAccepted},
+ },
+ }
+ data, err := json.MarshalIndent(stale, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, PIILedgerFilename), data, 0644))
+
+ got := ReadPIILedger(dir)
+ require.NotNil(t, got, "stale ledger must be preserved (auto-deletion silently destroyed accumulated accepts)")
+ assert.Equal(t, 7, got.FindingsCountBefore)
+ assert.True(t, IsStalePIILedger(got))
+ _, statErr := os.Stat(filepath.Join(dir, PIILedgerFilename))
+ assert.NoError(t, statErr, "ledger file must survive on disk for resumable agent state")
+}
+
+// ---------------------------------------------------------------------------
+// Reconcile
+// ---------------------------------------------------------------------------
+
+func TestReconcilePIILedger_PreservesAcceptedState(t *testing.T) {
+ previous := &PIILedger{
+ Findings: []PIIFinding{
+ {
+ Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "alice@example.com",
+ Status: PIIStatusAccepted, Category: PIICategoryDocumentationExample,
+ EvidenceContext: "in example block",
+ },
+ },
+ }
+ current := []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "alice@example.com"},
+ }
+ delta := ReconcilePIILedger(previous, current)
+ assert.True(t, delta.HasPrevious)
+ assert.Empty(t, delta.Added)
+ assert.Empty(t, delta.Resolved)
+ assert.Equal(t, PIIStatusAccepted, current[0].Status)
+ assert.Equal(t, PIICategoryDocumentationExample, current[0].Category)
+ assert.Equal(t, "in example block", current[0].EvidenceContext)
+}
+
+func TestReconcilePIILedger_ResolvedAndAdded(t *testing.T) {
+ previous := &PIILedger{
+ Findings: []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "alice@example.com"},
+ {Kind: PIIKindEmail, File: "b.json", Line: 1, MatchedSpan: "fixed@example.com"},
+ },
+ }
+ current := []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "alice@example.com"},
+ {Kind: PIIKindPhoneUS, File: "c.json", Line: 2, MatchedSpan: "(415) 555-0123"},
+ }
+ delta := ReconcilePIILedger(previous, current)
+ assert.True(t, delta.HasPrevious)
+ require.Len(t, delta.Resolved, 1)
+ assert.Equal(t, "b.json", delta.Resolved[0].File)
+ require.Len(t, delta.Added, 1)
+ assert.Equal(t, PIIKindPhoneUS, delta.Added[0].Kind)
+}
+
+// ---------------------------------------------------------------------------
+// Enforcement primitives
+// ---------------------------------------------------------------------------
+
+func TestMissingPIIAcceptFields(t *testing.T) {
+ tests := []struct {
+ name string
+ finding PIIFinding
+ missing bool
+ }{
+ {
+ name: "valid-accept",
+ finding: PIIFinding{
+ Status: PIIStatusAccepted, Category: PIICategoryAttribution,
+ EvidenceContext: "printer_name field",
+ },
+ missing: false,
+ },
+ {
+ name: "empty-category",
+ finding: PIIFinding{Status: PIIStatusAccepted, EvidenceContext: "ctx"},
+ missing: true,
+ },
+ {
+ name: "invalid-category",
+ finding: PIIFinding{
+ Status: PIIStatusAccepted, Category: "bogus", EvidenceContext: "ctx",
+ },
+ missing: true,
+ },
+ {
+ name: "empty-evidence",
+ finding: PIIFinding{Status: PIIStatusAccepted, Category: PIICategoryPlaceName},
+ missing: true,
+ },
+ {
+ name: "other-no-note",
+ finding: PIIFinding{
+ Status: PIIStatusAccepted, Category: PIICategoryOther, EvidenceContext: "ctx",
+ },
+ missing: true,
+ },
+ {
+ name: "other-with-note",
+ finding: PIIFinding{
+ Status: PIIStatusAccepted, Category: PIICategoryOther,
+ EvidenceContext: "ctx", Note: "explanation",
+ },
+ missing: false,
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ assert.Equal(t, tt.missing, missingPIIAcceptFields(tt.finding))
+ })
+ }
+}
+
+func TestDetectPIIDuplicateRationales(t *testing.T) {
+ mk := func(kind, note, category string, i int) PIIFinding {
+ return PIIFinding{
+ Kind: kind, File: "a.json", Line: i, MatchedSpan: "x",
+ Status: PIIStatusAccepted, Note: note, Category: category,
+ EvidenceContext: "ctx",
+ }
+ }
+
+ t.Run("under-threshold", func(t *testing.T) {
+ findings := []PIIFinding{
+ mk(PIIKindEmail, "false positive", PIICategoryOther, 1),
+ mk(PIIKindEmail, "false positive", PIICategoryOther, 2),
+ mk(PIIKindEmail, "false positive", PIICategoryOther, 3),
+ }
+ groups := detectPIIDuplicateRationales(findings, PIIDuplicateRationaleThreshold)
+ assert.Empty(t, groups, "3 < threshold 5")
+ })
+
+ t.Run("at-threshold-does-not-fire", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 5; i++ {
+ findings = append(findings, mk(PIIKindEmail, "false positive", PIICategoryOther, i))
+ }
+ groups := detectPIIDuplicateRationales(findings, PIIDuplicateRationaleThreshold)
+ assert.Empty(t, groups, "exactly 5 accepts must not fire (gate uses > threshold, not >=)")
+ })
+
+ t.Run("over-threshold", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 6; i++ {
+ findings = append(findings, mk(PIIKindEmail, "false positive", PIICategoryOther, i))
+ }
+ groups := detectPIIDuplicateRationales(findings, PIIDuplicateRationaleThreshold)
+ require.Len(t, groups, 1)
+ assert.Len(t, groups[0].Findings, 6)
+ })
+
+ t.Run("category-split", func(t *testing.T) {
+ // 6 accepts share the note text but split across two categories;
+ // each cluster has 3, neither exceeds threshold.
+ findings := []PIIFinding{
+ mk(PIIKindEmail, "same note", PIICategoryPlaceName, 1),
+ mk(PIIKindEmail, "same note", PIICategoryPlaceName, 2),
+ mk(PIIKindEmail, "same note", PIICategoryPlaceName, 3),
+ mk(PIIKindEmail, "same note", PIICategoryCorporateName, 4),
+ mk(PIIKindEmail, "same note", PIICategoryCorporateName, 5),
+ mk(PIIKindEmail, "same note", PIICategoryCorporateName, 6),
+ }
+ groups := detectPIIDuplicateRationales(findings, PIIDuplicateRationaleThreshold)
+ assert.Empty(t, groups, "category split keeps each cluster under threshold")
+ })
+}
+
+func TestCheckPIIAllAcceptedNoFixes(t *testing.T) {
+ accepted := func(i int) PIIFinding {
+ return PIIFinding{
+ Kind: PIIKindEmail, File: "a.json", Line: i, MatchedSpan: "x",
+ Status: PIIStatusAccepted, Category: PIICategoryPlaceName,
+ EvidenceContext: "ctx",
+ }
+ }
+
+ t.Run("baseline-below-threshold", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 5; i++ {
+ findings = append(findings, accepted(i))
+ }
+ previous := &PIILedger{FindingsCountBefore: 5}
+ assert.Nil(t, checkPIIAllAcceptedNoFixes(findings, previous))
+ })
+
+ t.Run("all-accepted-equals-baseline-fires", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 12; i++ {
+ findings = append(findings, accepted(i))
+ }
+ previous := &PIILedger{FindingsCountBefore: 12}
+ issue := checkPIIAllAcceptedNoFixes(findings, previous)
+ require.NotNil(t, issue)
+ assert.Equal(t, 12, issue.Baseline)
+ assert.Equal(t, 12, issue.Accepted)
+ })
+
+ t.Run("fixes-applied-doesnt-fire", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 6; i++ {
+ findings = append(findings, accepted(i))
+ }
+ previous := &PIILedger{FindingsCountBefore: 12}
+ assert.Nil(t, checkPIIAllAcceptedNoFixes(findings, previous), "6 < 12 means 6 fixed in source")
+ })
+
+ t.Run("pending-doesnt-fire", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 12; i++ {
+ f := accepted(i)
+ if i == 12 {
+ f.Status = ""
+ }
+ findings = append(findings, f)
+ }
+ previous := &PIILedger{FindingsCountBefore: 12}
+ assert.Nil(t, checkPIIAllAcceptedNoFixes(findings, previous))
+ })
+
+ t.Run("no-previous-fires-on-wholesale-first-run-accept", func(t *testing.T) {
+ var findings []PIIFinding
+ for i := 1; i <= 12; i++ {
+ findings = append(findings, accepted(i))
+ }
+ // First run with no prior baseline: 12 findings, all accepted,
+ // no source work happened — this is the wholesale-accept punt.
+ issue := checkPIIAllAcceptedNoFixes(findings, nil)
+ require.NotNil(t, issue)
+ assert.Equal(t, 12, issue.Current)
+ assert.Equal(t, 12, issue.Accepted)
+ })
+
+ t.Run("incremental-additions-trigger-gate", func(t *testing.T) {
+ // Regression for adversarial #12 (was: gate compared current vs
+ // baseline; when new findings appeared, current != baseline so
+ // gate never fired even if every finding was accepted). New
+ // gate fires when current >= threshold and all accepted, unless
+ // fixes happened (current < baseline).
+ var findings []PIIFinding
+ for i := 1; i <= 15; i++ {
+ findings = append(findings, accepted(i))
+ }
+ previous := &PIILedger{FindingsCountBefore: 10}
+ // current (15) > baseline (10) AND all accepted: this is the
+ // incremental-add bypass scenario — gate must fire.
+ issue := checkPIIAllAcceptedNoFixes(findings, previous)
+ require.NotNil(t, issue)
+ assert.Equal(t, 15, issue.Current)
+ })
+}
+
+func TestEvaluatePIICompletion_Clean(t *testing.T) {
+ findings := []PIIFinding{
+ {
+ Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "x",
+ Status: PIIStatusAccepted, Category: PIICategoryDocumentationExample,
+ EvidenceContext: "in example block",
+ },
+ }
+ previous := &PIILedger{FindingsCountBefore: 1}
+ c := EvaluatePIICompletion(findings, previous)
+ assert.False(t, c.HasGateFailure())
+ assert.Nil(t, c.NextPending)
+}
+
+func TestEvaluatePIICompletion_PendingShownAsNext(t *testing.T) {
+ findings := []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "x"},
+ }
+ c := EvaluatePIICompletion(findings, nil)
+ require.NotNil(t, c.NextPending)
+ assert.Equal(t, PIIKindEmail, c.NextPending.Kind)
+}
+
+// ---------------------------------------------------------------------------
+// Format helpers
+// ---------------------------------------------------------------------------
+
+func TestPIIPendingCount(t *testing.T) {
+ findings := []PIIFinding{
+ {Kind: PIIKindEmail, File: "a", Line: 1, MatchedSpan: "x"},
+ {
+ Kind: PIIKindEmail, File: "b", Line: 2, MatchedSpan: "y",
+ Status: PIIStatusAccepted, Category: PIICategoryPlaceName,
+ EvidenceContext: "ctx",
+ },
+ }
+ assert.Equal(t, 1, PIIPendingCount(findings))
+}
+
+func TestFormatPIIFindings_PendingOnly(t *testing.T) {
+ findings := []PIIFinding{
+ {Kind: PIIKindEmail, File: "a.json", Line: 5, MatchedSpan: "leak@example.com"},
+ {
+ Kind: PIIKindEmail, File: "b.json", Line: 3, MatchedSpan: "ok@example.com",
+ Status: PIIStatusAccepted, Category: PIICategoryDocumentationExample,
+ EvidenceContext: "ctx",
+ },
+ }
+ out := FormatPIIFindings(findings)
+ assert.Contains(t, out, "a.json:5")
+ assert.NotContains(t, out, "b.json:3")
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+func write(t *testing.T, path, content string) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
+ require.NoError(t, os.WriteFile(path, []byte(content), 0644))
+}
+
+func scanLine(t *testing.T, line, filename string) []PIIFinding {
+ t.Helper()
+ root := t.TempDir()
+ write(t, filepath.Join(root, filename), line+"\n")
+ findings, err := FindPII(root)
+ require.NoError(t, err)
+ return findings
+}
+
+func assertKinds(t *testing.T, findings []PIIFinding, expected []string, focus string) {
+ t.Helper()
+ var actual []string
+ for _, f := range findings {
+ // Filter to detector we're testing so cross-detector overlap
+ // (e.g., phone regex catching a ZIP-like number) doesn't break
+ // the focused assertion.
+ if f.Kind == focus {
+ actual = append(actual, f.Kind)
+ }
+ }
+ if expected == nil {
+ assert.Empty(t, actual)
+ return
+ }
+ assert.Equal(t, expected, actual)
+}
+
+func uniqueFiles(findings []PIIFinding) []string {
+ seen := map[string]bool{}
+ var out []string
+ for _, f := range findings {
+ if !seen[f.File] {
+ seen[f.File] = true
+ out = append(out, f.File)
+ }
+ }
+ return out
+}
diff --git a/internal/cli/pii_audit.go b/internal/cli/pii_audit.go
new file mode 100644
index 00000000..1dd90d1d
--- /dev/null
+++ b/internal/cli/pii_audit.go
@@ -0,0 +1,154 @@
+package cli
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
+ "github.com/spf13/cobra"
+)
+
+// newPIIAuditCmd inspects a printed CLI's high-risk files for PII-shape
+// matches. Mirrors `tools-audit` semantics: detection is purely
+// mechanical, agent judgment runs through the polish skill's pii-polish
+// playbook, and the ledger at <cli-dir>/.printing-press-pii-polish.json
+// persists agent decisions across runs.
+//
+// Default exit is 0 (diagnostic). The `--strict` flag exits non-zero
+// when pending findings or gate failures remain — for external CI
+// callers that shell out to `printing-press pii-audit`. The in-process
+// promote/publish gates call artifacts.RunPIIAudit directly and apply
+// equivalent enforcement.
+func newPIIAuditCmd() *cobra.Command {
+ var asJSON bool
+ var strict bool
+
+ cmd := &cobra.Command{
+ Use: "pii-audit <cli-dir>",
+ Short: "Mechanically audit a printed CLI's high-risk files for customer-PII shapes",
+ Long: `Walks <cli-dir>'s high-risk file scope (JSON, YAML, Markdown,
+*_test.go, .manuscripts/**, testdata/**) and reports per-line findings
+that signal PII-shape leaks. Detection is purely mechanical: card
+last-4 with context tokens, email addresses, US-shaped phone numbers,
+ZIP+4, and street-address-line shapes. The agent layer
+(skills/printing-press-polish/references/pii-polish.md) takes these
+findings and applies judgment per item — fix in source or accept with
+pre-decision fields.
+
+Exit 0 by default (diagnostic). With --strict, exits non-zero when
+pending findings or gate failures remain.`,
+ Example: ` printing-press pii-audit ~/printing-press/library/dub
+ printing-press pii-audit ~/printing-press/library/dub --json
+ printing-press pii-audit ~/printing-press/library/dub --strict`,
+ Args: cobra.ExactArgs(1),
+ Annotations: map[string]string{
+ // No mcp:read-only — RunPIIAudit writes a ledger file under
+ // <cli-dir>, which is user-visible mutation outside the
+ // local cache. Per AGENTS.md, a false readOnlyHint on a
+ // mutating tool is a real bug.
+ "pp:typed-exit-codes": "0,3",
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ cliDir := args[0]
+ info, err := os.Stat(cliDir)
+ if err != nil {
+ return fmt.Errorf("cli-dir %q: %w", cliDir, err)
+ }
+ if !info.IsDir() {
+ return fmt.Errorf("cli-dir %q is not a directory", cliDir)
+ }
+
+ var result artifacts.PIIAuditResult
+ if asJSON {
+ // --json is a read-only probe; do not write the ledger.
+ result, err = artifacts.ScanPII(cliDir)
+ } else {
+ result, err = artifacts.RunPIIAudit(cliDir)
+ }
+ if err != nil {
+ return err
+ }
+
+ if asJSON {
+ enc := json.NewEncoder(cmd.OutOrStdout())
+ enc.SetIndent("", " ")
+ if err := enc.Encode(result.Findings); err != nil {
+ return err
+ }
+ } else {
+ renderPIIAuditTable(cmd.OutOrStdout(), result.Findings, result.Delta, result.Completion)
+ }
+
+ if strict && (artifacts.PIIPendingCount(result.Findings) > 0 || result.Completion.HasGateFailure()) {
+ return &ExitError{
+ Code: ExitGenerationError,
+ Err: fmt.Errorf("pii-audit: %d pending finding(s), %d gate failure(s)",
+ artifacts.PIIPendingCount(result.Findings), result.Completion.GateFailureCount()),
+ }
+ }
+ return nil
+ },
+ }
+
+ cmd.Flags().BoolVar(&asJSON, "json", false, "emit JSON instead of a human-readable table")
+ cmd.Flags().BoolVar(&strict, "strict", false, "exit non-zero when pending findings or gate failures remain")
+ return cmd
+}
+
+func renderPIIAuditTable(w io.Writer, findings []artifacts.PIIFinding, delta artifacts.PIILedgerDelta, completion artifacts.PIICompletionStatus) {
+ pending := artifacts.PIIPendingCount(findings)
+ accepted := len(findings) - pending
+ gateFired := completion.HasGateFailure()
+
+ switch {
+ case pending == 0 && !gateFired:
+ if accepted > 0 {
+ fmt.Fprintf(w, "pii-audit: no pending findings (%d accepted) — phase-1 scope (card/email/phone/zip/postal); order-IDs, ASINs, and standalone names are a future detector class\n", accepted)
+ } else {
+ fmt.Fprintln(w, "pii-audit: no findings — phase-1 scope (card/email/phone/zip/postal); order-IDs, ASINs, and standalone names are a future detector class")
+ }
+ case pending == 0 && gateFired:
+ fmt.Fprintf(w, "pii-audit: incomplete (%d accepted, %d gate failure(s))\n",
+ accepted, completion.GateFailureCount())
+ default:
+ fmt.Fprintf(w, "pii-audit: %d pending finding(s)", pending)
+ if accepted > 0 {
+ fmt.Fprintf(w, " (%d accepted)", accepted)
+ }
+ if gateFired {
+ fmt.Fprintf(w, ", %d gate failure(s)", completion.GateFailureCount())
+ }
+ fmt.Fprintln(w)
+ }
+
+ if delta.HasPrevious {
+ fmt.Fprintf(w, "since last run: %d resolved, %d new\n", len(delta.Resolved), len(delta.Added))
+ }
+
+ if gateFired {
+ fmt.Fprintln(w)
+ fmt.Fprintln(w, "incomplete: the run is not done yet")
+ fmt.Fprintln(w, artifacts.FormatPIIGateFailures(completion))
+ }
+
+ if pending > 0 {
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "%-16s %-6s %-12s %-40s %s\n", "KIND", "LINE", "ID", "FILE", "MATCH")
+ for _, f := range findings {
+ if f.Status == artifacts.PIIStatusAccepted {
+ continue
+ }
+ fmt.Fprintf(w, "%-16s %-6d %-12s %-40s %s\n",
+ f.Kind, f.Line, artifacts.PIIFindingID(f),
+ truncate(f.File, 40), truncate(f.MatchedSpan, 60))
+ }
+ }
+
+ if completion.NextPending != nil {
+ f := completion.NextPending
+ fmt.Fprintln(w)
+ fmt.Fprintf(w, "next: %s [%s] at %s:%d\n", f.Kind, artifacts.PIIFindingID(*f), f.File, f.Line)
+ }
+}
diff --git a/internal/cli/pii_audit_test.go b/internal/cli/pii_audit_test.go
new file mode 100644
index 00000000..d4fb91f5
--- /dev/null
+++ b/internal/cli/pii_audit_test.go
@@ -0,0 +1,205 @@
+package cli
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPIIAuditCmd_RunsAndPersistsLedger(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "data.json"), `"email": "leak@example.com"`+"\n")
+
+ cmd := newPIIAuditCmd()
+ stdout := &bytes.Buffer{}
+ stderr := &bytes.Buffer{}
+ cmd.SetOut(stdout)
+ cmd.SetErr(stderr)
+ cmd.SetArgs([]string{dir})
+
+ require.NoError(t, cmd.Execute())
+
+ // Default exit 0 even with pending findings
+ assert.Contains(t, stdout.String(), "pending finding")
+
+ // Ledger persisted
+ _, err := os.Stat(filepath.Join(dir, artifacts.PIILedgerFilename))
+ require.NoError(t, err)
+}
+
+func TestPIIAuditCmd_JSON(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "data.json"), `"email": "leak@example.com"`+"\n")
+
+ cmd := newPIIAuditCmd()
+ stdout := &bytes.Buffer{}
+ cmd.SetOut(stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{dir, "--json"})
+
+ require.NoError(t, cmd.Execute())
+
+ var findings []artifacts.PIIFinding
+ require.NoError(t, json.Unmarshal(stdout.Bytes(), &findings))
+ require.Len(t, findings, 1)
+ assert.Equal(t, artifacts.PIIKindEmail, findings[0].Kind)
+}
+
+func TestPIIAuditCmd_StrictExitsNonZeroOnPending(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "data.json"), `"email": "leak@example.com"`+"\n")
+
+ cmd := newPIIAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{dir, "--strict"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ var exitErr *ExitError
+ require.True(t, errors.As(err, &exitErr))
+ assert.Equal(t, ExitGenerationError, exitErr.Code)
+}
+
+func TestPIIAuditCmd_StrictPassesWithValidAccepts(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "data.json"), `"email": "leak@example.com"`+"\n")
+
+ // Run once to populate ledger
+ cmd := newPIIAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{dir})
+ require.NoError(t, cmd.Execute())
+
+ // Mutate ledger to accept the finding with valid pre-decision fields
+ mutateLedger(t, dir, func(l *artifacts.PIILedger) {
+ for i := range l.Findings {
+ l.Findings[i].Status = artifacts.PIIStatusAccepted
+ l.Findings[i].Category = artifacts.PIICategoryDocumentationExample
+ l.Findings[i].EvidenceContext = "example email used in README documentation block"
+ }
+ })
+
+ // Re-run with --strict; should now pass
+ cmd2 := newPIIAuditCmd()
+ cmd2.SetOut(&bytes.Buffer{})
+ cmd2.SetErr(&bytes.Buffer{})
+ cmd2.SetArgs([]string{dir, "--strict"})
+ assert.NoError(t, cmd2.Execute())
+}
+
+func TestPIIAuditCmd_StrictFailsOnAcceptMissingCategory(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "data.json"), `"email": "leak@example.com"`+"\n")
+
+ cmd := newPIIAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{dir})
+ require.NoError(t, cmd.Execute())
+
+ mutateLedger(t, dir, func(l *artifacts.PIILedger) {
+ for i := range l.Findings {
+ l.Findings[i].Status = artifacts.PIIStatusAccepted
+ // Note: category intentionally omitted
+ l.Findings[i].EvidenceContext = "ctx"
+ }
+ })
+
+ cmd2 := newPIIAuditCmd()
+ stdout := &bytes.Buffer{}
+ cmd2.SetOut(stdout)
+ cmd2.SetErr(&bytes.Buffer{})
+ cmd2.SetArgs([]string{dir, "--strict"})
+ err := cmd2.Execute()
+ require.Error(t, err)
+ assert.Contains(t, stdout.String(), "pre-decision fields")
+}
+
+func TestPIIAuditCmd_AgentFieldsSurviveReRun(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "data.json"), `"email": "leak@example.com"`+"\n")
+
+ // Run once
+ cmd := newPIIAuditCmd()
+ cmd.SetOut(&bytes.Buffer{})
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{dir})
+ require.NoError(t, cmd.Execute())
+
+ // Agent accepts the finding
+ mutateLedger(t, dir, func(l *artifacts.PIILedger) {
+ for i := range l.Findings {
+ l.Findings[i].Status = artifacts.PIIStatusAccepted
+ l.Findings[i].Category = artifacts.PIICategoryDocumentationExample
+ l.Findings[i].EvidenceContext = "ctx"
+ }
+ })
+
+ // Re-run; agent field should be preserved
+ cmd2 := newPIIAuditCmd()
+ cmd2.SetOut(&bytes.Buffer{})
+ cmd2.SetErr(&bytes.Buffer{})
+ cmd2.SetArgs([]string{dir})
+ require.NoError(t, cmd2.Execute())
+
+ ledger := artifacts.ReadPIILedger(dir)
+ require.NotNil(t, ledger)
+ require.Len(t, ledger.Findings, 1)
+ assert.Equal(t, artifacts.PIIStatusAccepted, ledger.Findings[0].Status)
+ assert.Equal(t, artifacts.PIICategoryDocumentationExample, ledger.Findings[0].Category)
+}
+
+func TestPIIAuditCmd_CleanDir(t *testing.T) {
+ dir := t.TempDir()
+ writeFile(t, filepath.Join(dir, "clean.json"), `"version": "1.2.3"`+"\n")
+
+ cmd := newPIIAuditCmd()
+ stdout := &bytes.Buffer{}
+ cmd.SetOut(stdout)
+ cmd.SetErr(&bytes.Buffer{})
+ cmd.SetArgs([]string{dir})
+ require.NoError(t, cmd.Execute())
+
+ assert.Contains(t, stdout.String(), "no findings")
+
+ ledger := artifacts.ReadPIILedger(dir)
+ require.NotNil(t, ledger)
+ assert.Equal(t, 0, ledger.FindingsCountBefore)
+}
+
+func TestPIIAuditCmd_TypedExitCodesAnnotation(t *testing.T) {
+ cmd := newPIIAuditCmd()
+ assert.Equal(t, "0,3", cmd.Annotations["pp:typed-exit-codes"])
+ // Absent mcp:read-only annotation — pii-audit writes a ledger file.
+ _, hasReadOnly := cmd.Annotations["mcp:read-only"]
+ assert.False(t, hasReadOnly, "pii-audit must not claim mcp:read-only; it mutates the cli-dir by writing a ledger")
+}
+
+// ---------------------------------------------------------------------------
+// helpers
+// ---------------------------------------------------------------------------
+
+func writeFile(t *testing.T, path, content string) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(filepath.Dir(path), 0755))
+ require.NoError(t, os.WriteFile(path, []byte(content), 0644))
+}
+
+func mutateLedger(t *testing.T, dir string, mutate func(*artifacts.PIILedger)) {
+ t.Helper()
+ ledger := artifacts.ReadPIILedger(dir)
+ require.NotNil(t, ledger)
+ mutate(ledger)
+ data, err := json.MarshalIndent(ledger, "", " ")
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dir, artifacts.PIILedgerFilename), append(data, '\n'), 0644))
+}
diff --git a/internal/cli/publish.go b/internal/cli/publish.go
index 66989251..e578dd3c 100644
--- a/internal/cli/publish.go
+++ b/internal/cli/publish.go
@@ -384,12 +384,16 @@ func newPublishPackageCmd() *cobra.Command {
cleanupOnFailure()
return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("scanning staged package for vendor-prefix tokens: %w", err)}
}
- if len(findings) > 0 {
+
+ piiResult, piiErr := artifacts.RunPIIAudit(outCLIDir)
+ if piiErr != nil {
cleanupOnFailure()
- return &ExitError{
- Code: ExitPublishError,
- Err: fmt.Errorf("vendor-prefix tokens detected in staged package:\n%s", artifacts.FormatVendorPrefixSecretFindings(findings)),
- }
+ return &ExitError{Code: ExitPublishError, Err: fmt.Errorf("scanning staged package for PII: %w", piiErr)}
+ }
+
+ if scanErr := formatCombinedScanError(findings, piiResult.Findings, piiResult.Completion); scanErr != nil {
+ cleanupOnFailure()
+ return &ExitError{Code: ExitPublishError, Err: scanErr}
}
// Success — remove stashed old CLI dirs
@@ -1015,3 +1019,35 @@ func hasContent(dir string) bool {
}
return false
}
+
+// formatCombinedScanError composes the publish-time error message from
+// both scanners. Sections appear in fixed order: vendor-prefix tokens,
+// then PII pending findings, then PII gate failures. Returns nil when
+// nothing to report so callers can branch on the error directly.
+func formatCombinedScanError(
+ secretFindings []artifacts.VendorPrefixSecretFinding,
+ piiFindings []artifacts.PIIFinding,
+ piiCompletion artifacts.PIICompletionStatus,
+) error {
+ var sections []string
+
+ if len(secretFindings) > 0 {
+ sections = append(sections,
+ "vendor-prefix tokens detected in staged package:\n"+
+ artifacts.FormatVendorPrefixSecretFindings(secretFindings))
+ }
+ if artifacts.PIIPendingCount(piiFindings) > 0 {
+ sections = append(sections,
+ "customer PII detected in staged package:\n"+
+ artifacts.FormatPIIFindings(piiFindings))
+ }
+ if piiCompletion.HasGateFailure() {
+ sections = append(sections,
+ "PII gate failures:\n"+
+ artifacts.FormatPIIGateFailures(piiCompletion))
+ }
+ if len(sections) == 0 {
+ return nil
+ }
+ return fmt.Errorf("%s", strings.Join(sections, "\n\n"))
+}
diff --git a/internal/cli/publish_test.go b/internal/cli/publish_test.go
index 3923e195..674f91b8 100644
--- a/internal/cli/publish_test.go
+++ b/internal/cli/publish_test.go
@@ -648,6 +648,81 @@ func TestPublishPackageRejectsVendorPrefixSecretsInStagedCLI(t *testing.T) {
assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
}
+func TestPublishPackageRejectsPIIInStagedCLI(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+ // Plant real-shaped PII in a high-risk file
+ require.NoError(t, os.WriteFile(
+ filepath.Join(cliDir, "data.json"),
+ []byte(`{"customer_email": "alice@example.com"}`+"\n"),
+ 0o644,
+ ))
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "customer PII detected")
+ assert.Contains(t, err.Error(), "email")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
+}
+
+func TestPublishPackageRejectsPIIInManuscripts(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+ runID := "20260329-100000"
+ researchFile := filepath.Join(home, "manuscripts", "test", runID, "research", "captured.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(researchFile), 0o755))
+ require.NoError(t, os.WriteFile(researchFile, []byte(`{"recipient_email": "leak@example.com"}`+"\n"), 0o644))
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "customer PII detected")
+ assert.Contains(t, err.Error(), "captured.json")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target")
+}
+
+func TestPublishPackageCombinesSecretAndPIIReports(t *testing.T) {
+ home := setLibraryTestEnv(t)
+ cliDir := filepath.Join(home, "library", "test-pp-cli")
+ writePublishableTestCLI(t, cliDir)
+ // Plant both a secret AND PII
+ require.NoError(t, os.WriteFile(
+ filepath.Join(cliDir, "spec.json"),
+ []byte("{\n \"token\": \""+testSecret("sk", "-or-v1-", "abcdefghijklmnopqrstuvwxyz1234567890")+"\"\n}\n"),
+ 0o644,
+ ))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(cliDir, "data.json"),
+ []byte(`{"customer_email": "alice@example.com"}`+"\n"),
+ 0o644,
+ ))
+
+ target := filepath.Join(t.TempDir(), "staging")
+ cmd := newPublishCmd()
+ cmd.SetArgs([]string{"package", "--dir", cliDir, "--category", "other", "--target", target, "--json"})
+
+ err := cmd.Execute()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "vendor-prefix tokens detected")
+ assert.Contains(t, err.Error(), "customer PII detected")
+
+ _, statErr := os.Stat(target)
+ assert.ErrorIs(t, statErr, os.ErrNotExist, "failed packaging should clean up the staging target even on combined-error path")
+}
+
func TestPublishPackageRejectsVendorPrefixSecretsInManuscripts(t *testing.T) {
home := setLibraryTestEnv(t)
cliDir := filepath.Join(home, "library", "test-pp-cli")
diff --git a/internal/cli/root.go b/internal/cli/root.go
index 85bf7748..077e709a 100644
--- a/internal/cli/root.go
+++ b/internal/cli/root.go
@@ -71,6 +71,7 @@ func Execute() error {
rootCmd.AddCommand(newMCPAuditCmd())
rootCmd.AddCommand(newToolsAuditCmd())
rootCmd.AddCommand(newPublicParamAuditCmd())
+ rootCmd.AddCommand(newPIIAuditCmd())
rootCmd.AddCommand(newProbeReachabilityCmd())
rootCmd.AddCommand(newSchemaCmd())
rootCmd.AddCommand(newBundleCmd())
diff --git a/internal/cli/tools_audit.go b/internal/cli/tools_audit.go
index a58f3fc2..1582f65e 100644
--- a/internal/cli/tools_audit.go
+++ b/internal/cli/tools_audit.go
@@ -13,12 +13,13 @@ import (
"strings"
"time"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
"github.com/mvanhorn/cli-printing-press/v4/internal/pipeline"
"github.com/spf13/cobra"
)
const (
- ledgerFilename = ".printing-press-tools-polish.json"
+ ledgerFilename = artifacts.ToolsPolishLedgerFilename
ledgerStaleAfter = 24 * time.Hour
statusAccepted = "accepted"
suspiciousMaxLen = 30
diff --git a/internal/pipeline/lock.go b/internal/pipeline/lock.go
index e30352e1..2b7f42de 100644
--- a/internal/pipeline/lock.go
+++ b/internal/pipeline/lock.go
@@ -5,8 +5,10 @@ import (
"fmt"
"os"
"path/filepath"
+ "strings"
"time"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
"github.com/mvanhorn/cli-printing-press/v4/internal/naming"
)
@@ -229,6 +231,9 @@ func PromoteWorkingCLI(cliName, workingDir string, state *PipelineState) error {
if err := validatePhase5GateForPromote(workingDir, state); err != nil {
return err
}
+ if err := validatePIIGateForPromote(workingDir); err != nil {
+ return err
+ }
slug := naming.TrimCLISuffix(cliName)
libraryDir := filepath.Join(PublishedLibraryRoot(), slug)
@@ -351,6 +356,45 @@ func validatePhase5GateForPromote(workingDir string, state *PipelineState) error
return fmt.Errorf("phase5 gate failed: %s", result.Detail)
}
+// validatePIIGateForPromote runs the PII audit against the working
+// directory and refuses promote when pending findings or enforcement-
+// primitive failures remain. The audit's ledger is refreshed in place
+// (every call rewrites the ledger with the current timestamp and
+// FindingsCountBefore baseline) so agent-written accepts from a prior
+// polish run carry forward and new findings since polish surface as
+// pending. The error message points operators at the ledger file and
+// the pii-polish playbook.
+func validatePIIGateForPromote(workingDir string) error {
+ result, err := artifacts.RunPIIAudit(workingDir)
+ if err != nil {
+ return fmt.Errorf("PII gate failed (scan error): %w", err)
+ }
+ pending := artifacts.PIIPendingCount(result.Findings)
+ if pending == 0 && !result.Completion.HasGateFailure() {
+ return nil
+ }
+
+ var parts []string
+ if pending > 0 {
+ parts = append(parts, fmt.Sprintf("%d pending PII finding(s)", pending))
+ }
+ if result.Completion.HasGateFailure() {
+ parts = append(parts, fmt.Sprintf("%d gate failure(s)", result.Completion.GateFailureCount()))
+ }
+ ledgerPath := filepath.Join(workingDir, artifacts.PIILedgerFilename)
+ msg := fmt.Sprintf("PII gate failed: %s\n", strings.Join(parts, ", "))
+ if pending > 0 {
+ msg += "pending findings:\n" + artifacts.FormatPIIFindings(result.Findings) + "\n"
+ }
+ if result.Completion.HasGateFailure() {
+ msg += "gate failures:\n" + artifacts.FormatPIIGateFailures(result.Completion) + "\n"
+ }
+ msg += fmt.Sprintf("ledger: %s\n", ledgerPath)
+ msg += "scope: phase-1 detectors (card-last-4, email, phone, ZIP+4, postal-address); order-IDs, ASINs, and standalone names are a future detector class.\n"
+ msg += "run `printing-press pii-audit <dir>` and follow skills/printing-press-polish/references/pii-polish.md"
+ return fmt.Errorf("%s", msg)
+}
+
// IsStale returns true if the lock's heartbeat is too old or its owner
// process is known to have exited.
func IsStale(lock *LockState) bool {
diff --git a/internal/pipeline/lock_test.go b/internal/pipeline/lock_test.go
index 5eedaf74..b9515b3f 100644
--- a/internal/pipeline/lock_test.go
+++ b/internal/pipeline/lock_test.go
@@ -4,10 +4,12 @@ import (
"encoding/json"
"os"
"path/filepath"
+ "strings"
"sync"
"testing"
"time"
+ "github.com/mvanhorn/cli-printing-press/v4/internal/artifacts"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -689,3 +691,164 @@ func setLockOwnerAliveForTest(t *testing.T, alive bool) {
lockOwnerAliveFunc = func(pid int) bool { return alive }
t.Cleanup(func() { lockOwnerAliveFunc = original })
}
+
+// ---------------------------------------------------------------------------
+// PII gate (U3)
+// ---------------------------------------------------------------------------
+
+func TestPromoteWorkingCLI_PIIGateHaltsOnPendingFindings(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ // Plant real-shaped PII in a high-risk file
+ require.NoError(t, os.WriteFile(
+ filepath.Join(workDir, "data.json"),
+ []byte(`{"customer_email": "alice@example.com"}`+"\n"),
+ 0o644,
+ ))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-pii-1", "test-scope")
+ writePhase5PassForState(t, state, "none")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "PII gate failed")
+ assert.Contains(t, err.Error(), "pending")
+ assert.Contains(t, err.Error(), "pii-polish.md")
+
+ // Library should NOT have been created
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
+ _, statErr := os.Stat(libDir)
+ assert.True(t, os.IsNotExist(statErr), "library dir should not exist when PII gate halts")
+}
+
+func TestPromoteWorkingCLI_PIIGatePassesWithValidAccepts(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(workDir, "data.json"),
+ []byte(`{"customer_email": "alice@example.com"}`+"\n"),
+ 0o644,
+ ))
+
+ // Pre-populate the ledger with the accepted finding (simulating
+ // completed polish run)
+ preflight, err := artifacts.FindPII(workDir)
+ require.NoError(t, err)
+ require.Len(t, preflight, 1)
+ preflight[0].Status = artifacts.PIIStatusAccepted
+ preflight[0].Category = artifacts.PIICategoryDocumentationExample
+ preflight[0].EvidenceContext = "documented example in customer-email README block"
+ require.NoError(t, artifacts.WritePIILedger(workDir, &artifacts.PIILedger{
+ Timestamp: time.Now().UTC(),
+ CLIDir: workDir,
+ Findings: preflight,
+ FindingsCountBefore: 1,
+ }))
+
+ _, err = AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-pii-2", "test-scope")
+ writePhase5PassForState(t, state, "none")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+
+ // Library should now exist
+ libDir := filepath.Join(PublishedLibraryRoot(), "test")
+ _, statErr := os.Stat(filepath.Join(libDir, "go.mod"))
+ assert.NoError(t, statErr)
+
+ // Ledger should be carried into the published library
+ _, statErr = os.Stat(filepath.Join(libDir, artifacts.PIILedgerFilename))
+ assert.NoError(t, statErr, "ledger should travel with the staged copy")
+}
+
+func TestPromoteWorkingCLI_PIIGateHaltsOnGateFailure(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ // Six findings, all accepted with identical rationale → triggers
+ // duplicate-rationale gate (threshold 5)
+ var lines []string
+ for i := range 6 {
+ lines = append(lines, `"email": "user`+string(rune('A'+i))+`@example.com"`)
+ }
+ require.NoError(t, os.WriteFile(
+ filepath.Join(workDir, "data.json"),
+ []byte(strings.Join(lines, "\n")+"\n"),
+ 0o644,
+ ))
+
+ preflight, err := artifacts.FindPII(workDir)
+ require.NoError(t, err)
+ require.Len(t, preflight, 6)
+ for i := range preflight {
+ preflight[i].Status = artifacts.PIIStatusAccepted
+ preflight[i].Category = artifacts.PIICategoryOther
+ preflight[i].EvidenceContext = "ctx"
+ preflight[i].Note = "false positive"
+ }
+ require.NoError(t, artifacts.WritePIILedger(workDir, &artifacts.PIILedger{
+ Timestamp: time.Now().UTC(),
+ CLIDir: workDir,
+ Findings: preflight,
+ FindingsCountBefore: 6,
+ }))
+
+ _, err = AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-pii-3", "test-scope")
+ writePhase5PassForState(t, state, "none")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "gate failure")
+ assert.Contains(t, err.Error(), "share rationale")
+}
+
+func TestPromoteWorkingCLI_PIIGatePassesCleanDir(t *testing.T) {
+ tmp := t.TempDir()
+ t.Setenv("PRINTING_PRESS_HOME", tmp)
+ t.Setenv("PRINTING_PRESS_SCOPE", "test-scope")
+ t.Setenv("PRINTING_PRESS_REPO_ROOT", tmp)
+
+ workDir := filepath.Join(tmp, "working", "test-pp-cli")
+ require.NoError(t, os.MkdirAll(workDir, 0o755))
+ require.NoError(t, os.WriteFile(filepath.Join(workDir, "go.mod"), []byte("module test-pp-cli\n\ngo 1.21\n"), 0o644))
+ require.NoError(t, os.WriteFile(
+ filepath.Join(workDir, "data.json"),
+ []byte(`{"version": "1.2.3", "port": 8080}`+"\n"),
+ 0o644,
+ ))
+
+ _, err := AcquireLock("test-pp-cli", "test-scope", false)
+ require.NoError(t, err)
+
+ state := NewStateWithRun("test", workDir, "run-pii-4", "test-scope")
+ writePhase5PassForState(t, state, "none")
+
+ err = PromoteWorkingCLI("test-pp-cli", workDir, state)
+ require.NoError(t, err)
+}
diff --git a/skills/printing-press-polish/SKILL.md b/skills/printing-press-polish/SKILL.md
index b5aa086b..e3ffde1e 100644
--- a/skills/printing-press-polish/SKILL.md
+++ b/skills/printing-press-polish/SKILL.md
@@ -207,10 +207,11 @@ The internal copy at `$CLI_DIR` can drift from the public library (`mvanhorn/pri
2. **Locate this CLI inside the clone.** `find <clone>/library -type d -name "<api>-pp-cli"` or equivalent.
3. **Run `diff -r <public-cli-dir> $CLI_DIR`** with these exclusions, all of which are expected to diverge after publish:
- `.printing-press-tools-polish.json` (local ledger, not published)
+ - `.printing-press-pii-polish.json` (local ledger, not published)
- `go.mod` and `go.sum` — publish rewrites the module path from `<api>-pp-cli` to `github.com/mvanhorn/printing-press-library/library/<category>/<api>`
- All `.go` files where the only difference is the rewritten import path (the publish step propagates the new module path through every internal import). When inspecting `.go` diffs, scan for substantive changes — anything beyond the module-path prefix swap is real divergence.
- Concretely: `diff -r --exclude=go.mod --exclude=go.sum --exclude=.printing-press-tools-polish.json <public-cli-dir> $CLI_DIR`.
+ Concretely: `diff -r --exclude=go.mod --exclude=go.sum --exclude=.printing-press-tools-polish.json --exclude=.printing-press-pii-polish.json <public-cli-dir> $CLI_DIR`.
Don't pass `--exclude='<api>-pp-cli'` or `--exclude='<api>-pp-mcp'` — those names match both the root-level binary files **and** the `cmd/<api>-pp-cli/` and `cmd/<api>-pp-mcp/` source directories. Excluding by binary name silently skips the entire `cmd/` subtree, hiding real divergence in `main.go`. The "Only in $CLI_DIR: <api>-pp-cli" line for the built binary is one row of expected output, not noise worth filtering at the cost of completeness.
4. **Surface the result** before continuing.
@@ -255,6 +256,7 @@ printing-press publish validate --dir "$CLI_DIR" --json > /tmp/polish-publish-va
printing-press scorecard --dir "$CLI_DIR" $SPEC_FLAG "${RESEARCH_ARGS[@]}" --live-check --json > /tmp/polish-scorecard.json 2>&1 || true
printing-press scorecard --dir "$CLI_DIR" $SPEC_FLAG 2>&1
printing-press tools-audit "$CLI_DIR" --json > /tmp/polish-tools-audit-before.json 2>&1 || true
+printing-press pii-audit "$CLI_DIR" --json > /tmp/polish-pii-audit-before.json 2>&1 || true
go vet ./... 2>&1
```
@@ -279,6 +281,7 @@ Parse findings into categories:
| Output entity warnings | scorecard JSON | `live_check.features[].warnings` — raw HTML entities in human output |
| Output plausibility | Phase 4.85 | Findings from the agentic output review |
| MCP tool quality | tools-audit | Empty Short, thin Short, missing read-only annotations, thin MCP descriptions |
+| Customer PII | pii-audit | Card last-4, email, phone, ZIP+4, postal-address shapes in high-risk files (manuscripts, fixtures, README) |
**Environmental failures vs. CLI defects.** Some Phase 1 outputs surface failures that aren't real CLI bugs and should not block ship:
@@ -494,6 +497,18 @@ Stop and:
Proceed to "After all fixes" only when the audit's summary line reads `no pending findings` with no `incomplete:` block — every gate (pre-decision fields, duplicate rationale, scorecard delta) passes.
+### Priority 7: Customer-PII gate
+
+**Your goal now is to clear the PII ledger so promote and publish gates pass.** The PII gate is the deterministic floor that prevents real customer values from reaching published library content. It catches card-last-4, email, US phone, ZIP+4, and postal-address shapes in high-risk files.
+
+Stop and:
+
+1. Run `printing-press pii-audit "$CLI_DIR"` to surface pending findings (or read `/tmp/polish-pii-audit-before.json` from Phase 1's baseline).
+2. You must read `references/pii-polish.md` and follow its per-finding decision tree — fix real values in source with non-matching placeholders, or accept with the `category` + `evidence_context` pre-decision fields.
+3. **Accepting PII findings carries a strict contract.** Missing fields, 6+ accepts sharing a rationale, or wholesale-accepting ≥10 findings without source fixes all fail the gate. See `references/pii-polish.md` "The accept contract" and "Forbidden accept patterns" for the full rules.
+
+Proceed to "After all fixes" only when `pii-audit` reads `no pending findings` with no `incomplete:` block.
+
### After all fixes
```bash
@@ -517,16 +532,17 @@ printing-press verify-skill --dir "$CLI_DIR" --json 2>&1
printing-press publish validate --dir "$CLI_DIR" --json 2>&1
printing-press scorecard --dir "$CLI_DIR" $SPEC_FLAG 2>&1
printing-press tools-audit "$CLI_DIR" 2>&1
+printing-press pii-audit "$CLI_DIR" 2>&1
go vet ./... 2>&1
```
-Record the after scores. If verify-skill still has any `severity=error` findings, workflow-verify still reports `workflow-fail`, or publish-validate still reports `passed: false`, ship cannot fire (see ship logic below).
+Record the after scores. If verify-skill still has any `severity=error` findings, workflow-verify still reports `workflow-fail`, publish-validate still reports `passed: false`, or pii-audit still has pending findings or gate failures, ship cannot fire (see ship logic below).
## Ship logic
Compute the ship recommendation:
-- **`ship`**: verify >= 80%, scorecard >= 75, no critical failures, **AND** verify-skill exits 0 (no SKILL/CLI mismatches), **AND** workflow-verify is not `workflow-fail`, **AND** publish-validate reports `passed: true`, **AND** tools-audit shows zero pending findings (every finding fixed or explicitly accepted with rationale). The SKILL/workflow/publish gates are hard requirements: a CLI that ships with a SKILL that lies about it (verify-skill findings) gives agents broken instructions; a CLI whose primary workflow fails verification has not actually shipped; a CLI that publish-validate rejects is not publishable.
+- **`ship`**: verify >= 80%, scorecard >= 75, no critical failures, **AND** verify-skill exits 0 (no SKILL/CLI mismatches), **AND** workflow-verify is not `workflow-fail`, **AND** publish-validate reports `passed: true`, **AND** tools-audit shows zero pending findings (every finding fixed or explicitly accepted with rationale), **AND** pii-audit shows zero pending findings and zero gate failures (every PII finding fixed in source or accepted with valid pre-decision fields). The SKILL/workflow/publish/PII gates are hard requirements: a CLI that ships with a SKILL that lies about it (verify-skill findings) gives agents broken instructions; a CLI whose primary workflow fails verification has not actually shipped; a CLI that publish-validate rejects is not publishable; a CLI that fails pii-audit at promote/publish gates will halt shipping anyway.
- **`ship-with-gaps`**: verify >= 65%, scorecard >= 65, non-critical gaps remain, **AND** the SKILL/workflow/publish gates above hold, **AND** the README has a `## Known Gaps` block that lists the user-facing gaps. Reserved for the rare case where a refactor or external-dependency blocker prevents a clean fix.
**README Known Gaps is mandatory for ship-with-gaps.** The published library copy is what downstream users see; if the verdict claims gaps exist but the README hides them, downstream users meet a CLI that misbehaves with no disclosure. Before emitting `ship_recommendation: ship-with-gaps`:
diff --git a/skills/printing-press-polish/references/pii-polish.md b/skills/printing-press-polish/references/pii-polish.md
new file mode 100644
index 00000000..0ec8e9ae
--- /dev/null
+++ b/skills/printing-press-polish/references/pii-polish.md
@@ -0,0 +1,95 @@
+# PII Polish — Customer-PII Gate Playbook
+
+**Goal:** clear the PII ledger so promote and publish gates pass. Every pending finding is either real PII (replace with a placeholder in source) or a justified non-PII match (accept in the ledger with pre-decision fields).
+
+**Scope:** Phase 1 detectors are shape-only — card-last-4, email, US phone, ZIP+4, postal-address. Order/transaction IDs, ASINs, and standalone names are a future detector class pending spec-aware detection work. A passing gate is the floor, not "PII-clean."
+
+```bash
+printing-press pii-audit <cli-dir>
+```
+
+The audit writes `<cli-dir>/.printing-press-pii-polish.json`. Promote and publish re-run the audit themselves, so the ledger header is the authority — they refuse if pending findings or gate failures remain.
+
+## The accept contract
+
+Every `status: "accepted"` entry must populate:
+
+- **`category`** — one of `attribution`, `place_name`, `corporate_name`, `documentation_example`, `api_provider_data`, `synthetic_placeholder`, `other`. The closed enum forces a specific claim about *what shape of non-PII this is*.
+- **`evidence_context`** — quote the surrounding 1-2 lines from the source. Lets a reviewer judge the category claim by reading only the ledger.
+- **`note`** — free-form; required only when `category` is `other`.
+
+```json
+{
+ "kind": "email",
+ "file": "data.json",
+ "line": 17,
+ "matched_span": "alice@example.com",
+ "status": "accepted",
+ "category": "documentation_example",
+ "evidence_context": "Inside README's 'Sample request' block, under a code fence demonstrating customer-email; reserved @example.com TLD."
+}
+```
+
+If `category` and `evidence_context` can't be filled honestly, the finding isn't yet understood. Re-read the source and either fix it or pick a sharper category.
+
+## Per-finding decision
+
+For each pending finding, read 1-2 lines on either side of the match and pick one outcome:
+
+| Outcome | When | What to do |
+|---|---|---|
+| **Fix in source** | Real customer value (captured order, sniff response, etc.) | Replace with the non-matching placeholder from the table below |
+| `category: api_provider_data` | Vendor's published example (Stripe docs use `4242`, support@vendor.com, etc.) | Cite vendor docs URL or captured field name in `evidence_context` |
+| `category: documentation_example` | Example value in README/SKILL prose teaching how a field works | Quote the documentation context |
+| `category: synthetic_placeholder` | Already a standards-reserved synthetic (`user@example.com`, `+1-555-01xx`, `90210-1234`, `1 EXAMPLE WAY`) | Name the convention in `evidence_context` |
+| `category: other` | Non-PII shape that matched (request ID, batch ID, version range hitting ZIP regex) | Required `note` names what the value actually is |
+
+### Replacement placeholders
+
+Use these to avoid re-flagging on the next audit:
+
+| Detector | Placeholder (does not re-match) |
+|---|---|
+| `card-last-4` | `PII_CARD_LAST4_EXAMPLE` |
+| `email` | `PII_EMAIL_EXAMPLE` |
+| `phone-us` | `PII_PHONE_EXAMPLE` |
+| `zip-plus-4` | `PII_ZIP_EXAMPLE` |
+| `postal-address` | `PII_ADDRESS_EXAMPLE` |
+
+When a downstream consumer (parser, schema validator) needs a valid-shape value, use the standards-reserved synthetics — `user@example.com`, `+1-555-0100`, `90210-1234`, `1 EXAMPLE WAY` — and accept the first audit finding as `synthetic_placeholder`. Subsequent audits preserve the accept.
+
+### Detector-specific notes
+
+- **`zip-plus-4`** has high false-positive rate. Request IDs, batch IDs, correlation IDs, and version ranges all match the shape. Most pending findings here resolve to `category: other` with a `note` naming the actual field, not real PII.
+- **`postal-address`** matches Title-Case and ALL-CAPS street names with the common US suffixes. Lowercase real addresses are a known gap.
+- **`card-last-4`** requires a context token (`card`/`visa`/`ending in`/`last 4`/4+ asterisks/x's). Bare 4-digit numbers don't match.
+
+## Special cases
+
+**Generated files (DO-NOT-EDIT).** Files carrying the `// Generated by CLI Printing Press ... DO NOT EDIT.` header are wiped on every regen — edits there don't survive. Trace upstream: the captured value came from a manuscript or spec file. Fix it at the source, regenerate, re-run the audit. The finding moves to the upstream file.
+
+**Manuscripts (`.manuscripts/<runID>/`).** Highest-risk source — captured browser-sniff content is by construction where customer values entered. Acceptance for manuscript findings requires the explicit `evidence_context`-cited justification; "captured data" alone is not sufficient. When in doubt, replace the value in the manuscript file directly (it's local working state) and use a hand-authored fixture with synthetic values for any downstream tests.
+
+## Forbidden accept patterns
+
+The binary enforces these via gate failures, not just convention:
+
+- **Identical rationale across 6+ findings** — clustered by normalized `note` + `category`. If you're stamping the same `note` repeatedly, fix the source content or file a follow-up to tighten the detector.
+- **Wholesale-accept punt** — when finding count is ≥ 10 and every finding is accepted with no source-level fixes, the all-accepted-no-fixes gate fires.
+- **Empty / placeholder `evidence_context`** — `"ctx"`, `"n/a"`, single chars all pass the `TrimSpace` check mechanically but PR review will catch them. Quote the file.
+
+## Resumability
+
+`progress.last_processed_finding_id` is an optional checkpoint to update after walking each finding. The audit's `next:` line uses it to resume mid-walk after a context flush. When absent, the audit resumes from the head.
+
+Finding identity is `(file, line, kind, normalized matched-span)`. Editing a file shifts line numbers — findings get fresh IDs and prior accepts no longer carry forward. This is by design: substantive churn deserves a fresh agent walk. Cosmetic edits (whitespace, case) don't change the ID.
+
+Don't manually mark findings as `fixed`. A real source fix makes the finding disappear from the next audit; the delta line shows `1 resolved`.
+
+## End-state checklist
+
+- [ ] `printing-press pii-audit <cli-dir>` summary reads `no pending findings`, no `incomplete:` block.
+- [ ] Every accepted finding has `category` + `evidence_context`. `category: other` also has `note`.
+- [ ] No 6+ accepts share a normalized `note` + `category`.
+- [ ] Real PII fixes landed in source (`git diff`).
+- [ ] If publishing into a public-library clone, add `.printing-press-pii-polish.json` to that repo's `.gitignore`.
← 4b04f4c6 fix(cli): reject control-plane flag injection in MCP shellou
·
back to Cli Printing Press
·
feat(cli): point users at where to get a token (URL + instru 4fac827e →