← back to Cli Printing Press
fix(skills): retro skill scrubs secrets and PII before posting issue bodies (#1595)
fef027fe2e41bba5c695463d86869dbfe985381c · 2026-05-17 12:16:04 -0700 · Trevin Chow
* fix(skills): retro skill scrubs secrets and PII before posting issue bodies
The retro skill already scrubs artifact zips (via references/secret-scrubbing.md
Layers 1-4) before catbox upload, but issue body text went straight from the
retro doc to `gh issue create` with no scrub step. A finding *about* a
secret-leak could quote the actual leaked value as "evidence" and re-leak it
in a public GitHub issue. The retro doc itself was also unscrubbed despite
being preserved in manuscript proofs and read by future runs' dedup scan.
This change closes both gaps:
1. **New cardinal rule** at the top of the retro skill's Cardinal rules
section: issue bodies and retro docs are public surfaces and must be
scrubbed before posting / writing. Specifically warns against the
"quote the leaked value as evidence" failure mode that retro findings
about secret leaks are most vulnerable to.
2. **New Layer 0 in `references/secret-scrubbing.md`**: a reusable
`scrub_body` shell function that operates on a single markdown file
(the retro doc or an issue body). Hard-fails on unredacted vendor-prefix
tokens (Stripe, GitHub, Slack, AWS, OpenRouter, Anthropic, Linear,
Mailchimp, JWT, Bearer-with-value); auto-redacts PII (real emails,
NANP phones, ZIP+4, Mailchimp inbox-ids) in place. Allowlists RFC 2606
reserved example domains and NANP fictional 555-01XX phone numbers
so legitimate documentation examples pass through unchanged.
3. **Phase 5 (retro doc write) scrubs the doc** immediately after writing
it, before the manuscript proofs + scratch copies are made canonical.
Hard-fails surface as a stop with explicit redaction instructions; PII
auto-redacts and writes the cleaned version in place.
4. **Phase 6 Step 3 (issue/comment post) scrubs body files** before each
`gh issue create` / `gh issue comment` call. Per-WU hard-fails route
into the existing `$FAILED_ISSUES` reporting as a new `scrub-failed`
outcome kind so the user sees what needs hand-redaction.
5. **Body template updated** to explicitly warn agents to redact in the
"What we observed" section of new issue bodies; references the Layer 0
shape so the agent has the redaction format at hand.
6. **New skill-level rule** added to the Rules section: never quote a
leaked secret as evidence of a secret-leak finding.
Verified end-to-end with smoke tests against four input shapes:
- clean input (no findings) passes through with zero diff
- Linear API key (`lin_api_<40+ chars>`) hard-fails with line numbers
- Mailchimp API key (`{32-hex}-us6`) hard-fails with line numbers
- PII (real email, NANP phone, ZIP+4, Mailchimp inbox-id) auto-redacts;
RFC 2606 example.com and NANP 555-01XX pass through unchanged
`go test ./internal/cli/...` green; `scripts/golden.sh verify` green.
Scope: skill markdown only, no Go code changes. No new binary subcommand
needed in this change; the scrubbing is bash-resident in the skill so
users running the skill outside a printing-press source checkout still
get the protection.
* fix(skills): retro scrub_body — fictional 555-01XX exclusion handles paren format (Greptile P2 on #1595)
The original NANP phone regex anchored the (?!555[-\s.]?01) negative lookahead
before the optional opening paren \(? — so when a fictional number is written
as `(555) 012-3456`, the position before the `(` is not where `555` lives,
the lookahead never fires, and the fictional number gets auto-redacted as
PII. API doc examples in that style would be mangled in scrubbed output.
Replace the negative lookahead with a capture-and-decide-inline approach:
extract the area code ($1) and exchange ($2), then check `$a eq "555" &&
$e =~ /^01/` inside a /e replacement.
Two Perl gotchas the new form has to handle:
1. **`$1`/`$&` aren't reliably accessible after the regex engine hands the
replacement to the callback.** Snapshot into lexicals immediately.
2. **The inner `$e =~ /^01/` test resets `$&` to "01".** Without snapshotting
`$&` before the inner match, the carve-out path returns just "01" instead
of the whole original phone string.
The fix snapshots `$&`, `$1`, `$2` into `$w`, `$a`, `$e` *before* running
the inner regex, then uses `$w` (lexical copy of the whole match) for the
preserved path.
Verified against an expanded test matrix:
REAL phones (redact):
425-761-6499, (425) 761-6499, +1 425-761-6499, +1 (425) 761-6499,
4257616499, 425.761.6499 — all → <REDACTED:phone-us>
FICTIONAL 555-01XX (pass through):
555-0142, 555-0100, (555) 012-3456, (555) 0123456, +1 555-012-3456,
+1 (555) 012-3456, 5550123456, 555.012.3456 — all preserved
OTHER PII (redact): emails, ZIP+4, Mailchimp inbox-id — all redact
ALLOWLIST (pass through): @example.com, @example.net, @example.invalid
HARD-FAIL (refuse to write): lin_api_*, {32-hex}-us\d{1,2} — all hard-fail
Comment + code update explain the Perl variable-lifetime trap so future
maintainers don't accidentally re-introduce it.
* fix(skills): preserve scrub-failed body files; reconcile inline-bodies principle with --body-file (Greptile P2 on #1595)
The retro skill's first scrub-body pass had two follow-up gaps Greptile flagged
on the re-review of d4d4c27f:
1. **The cleanup destroyed the recovery path it advertised.** The
`scrub-failed` failure message tells the agent the body file is "left at
$BODY_TMP for hand-redaction" — but the unconditional `rm -rf
"$ISSUE_TMPDIR"` at the end of the loop wiped the file before the agent
could read it. The recovery message was a lie.
Fix: cleanup is now conditional on the scrub-failed count. If any WUs
failed scrub, the temp dir survives so the body file is hand-redactable;
the loop emits a NOTE to stderr telling the user where the surviving
files are. The dir lives in `mktemp -d` so it self-cleans on the next
`tmpwatch` / OS reboot regardless. Clean runs (no scrub-failed) wipe the
dir as before.
2. **The "Execution principles" block forbade `--body-file`** because in
the original design body construction via the `Write` tool added a
per-WU tool round-trip — the single largest source of perceived
latency. The new scrub_body step writes to a bash-resident temp file
inside one `Bash` invocation, scrubs, then passes the scrubbed file to
`gh --body-file`. There are no extra tool round-trips; the principle's
*intent* (avoid Write-tool calls per issue) is still honored, but the
wording ("never pass --body-file") read as forbidding the new approach.
Fix: update the principle to specifically allow bash-resident temp files
while still forbidding per-WU Write-tool round-trips. Adds a one-line
explanation that `--body-file` is the natural consumer of the scrub
step's file output.
Both fixes flow from Greptile's framing: "the hard-fail protection works,
but the recovery path it advertises is broken" and "the stale execution
principle contradicts the new --body-file approach." Confidence on the new
shape should land at 4-5/5 after the re-review.
Files touched
M skills/printing-press-retro/SKILL.mdM skills/printing-press-retro/references/issue-template.mdM skills/printing-press-retro/references/secret-scrubbing.md
Diff
commit fef027fe2e41bba5c695463d86869dbfe985381c
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sun May 17 12:16:04 2026 -0700
fix(skills): retro skill scrubs secrets and PII before posting issue bodies (#1595)
* fix(skills): retro skill scrubs secrets and PII before posting issue bodies
The retro skill already scrubs artifact zips (via references/secret-scrubbing.md
Layers 1-4) before catbox upload, but issue body text went straight from the
retro doc to `gh issue create` with no scrub step. A finding *about* a
secret-leak could quote the actual leaked value as "evidence" and re-leak it
in a public GitHub issue. The retro doc itself was also unscrubbed despite
being preserved in manuscript proofs and read by future runs' dedup scan.
This change closes both gaps:
1. **New cardinal rule** at the top of the retro skill's Cardinal rules
section: issue bodies and retro docs are public surfaces and must be
scrubbed before posting / writing. Specifically warns against the
"quote the leaked value as evidence" failure mode that retro findings
about secret leaks are most vulnerable to.
2. **New Layer 0 in `references/secret-scrubbing.md`**: a reusable
`scrub_body` shell function that operates on a single markdown file
(the retro doc or an issue body). Hard-fails on unredacted vendor-prefix
tokens (Stripe, GitHub, Slack, AWS, OpenRouter, Anthropic, Linear,
Mailchimp, JWT, Bearer-with-value); auto-redacts PII (real emails,
NANP phones, ZIP+4, Mailchimp inbox-ids) in place. Allowlists RFC 2606
reserved example domains and NANP fictional 555-01XX phone numbers
so legitimate documentation examples pass through unchanged.
3. **Phase 5 (retro doc write) scrubs the doc** immediately after writing
it, before the manuscript proofs + scratch copies are made canonical.
Hard-fails surface as a stop with explicit redaction instructions; PII
auto-redacts and writes the cleaned version in place.
4. **Phase 6 Step 3 (issue/comment post) scrubs body files** before each
`gh issue create` / `gh issue comment` call. Per-WU hard-fails route
into the existing `$FAILED_ISSUES` reporting as a new `scrub-failed`
outcome kind so the user sees what needs hand-redaction.
5. **Body template updated** to explicitly warn agents to redact in the
"What we observed" section of new issue bodies; references the Layer 0
shape so the agent has the redaction format at hand.
6. **New skill-level rule** added to the Rules section: never quote a
leaked secret as evidence of a secret-leak finding.
Verified end-to-end with smoke tests against four input shapes:
- clean input (no findings) passes through with zero diff
- Linear API key (`lin_api_<40+ chars>`) hard-fails with line numbers
- Mailchimp API key (`{32-hex}-us6`) hard-fails with line numbers
- PII (real email, NANP phone, ZIP+4, Mailchimp inbox-id) auto-redacts;
RFC 2606 example.com and NANP 555-01XX pass through unchanged
`go test ./internal/cli/...` green; `scripts/golden.sh verify` green.
Scope: skill markdown only, no Go code changes. No new binary subcommand
needed in this change; the scrubbing is bash-resident in the skill so
users running the skill outside a printing-press source checkout still
get the protection.
* fix(skills): retro scrub_body — fictional 555-01XX exclusion handles paren format (Greptile P2 on #1595)
The original NANP phone regex anchored the (?!555[-\s.]?01) negative lookahead
before the optional opening paren \(? — so when a fictional number is written
as `(555) 012-3456`, the position before the `(` is not where `555` lives,
the lookahead never fires, and the fictional number gets auto-redacted as
PII. API doc examples in that style would be mangled in scrubbed output.
Replace the negative lookahead with a capture-and-decide-inline approach:
extract the area code ($1) and exchange ($2), then check `$a eq "555" &&
$e =~ /^01/` inside a /e replacement.
Two Perl gotchas the new form has to handle:
1. **`$1`/`$&` aren't reliably accessible after the regex engine hands the
replacement to the callback.** Snapshot into lexicals immediately.
2. **The inner `$e =~ /^01/` test resets `$&` to "01".** Without snapshotting
`$&` before the inner match, the carve-out path returns just "01" instead
of the whole original phone string.
The fix snapshots `$&`, `$1`, `$2` into `$w`, `$a`, `$e` *before* running
the inner regex, then uses `$w` (lexical copy of the whole match) for the
preserved path.
Verified against an expanded test matrix:
REAL phones (redact):
425-761-6499, (425) 761-6499, +1 425-761-6499, +1 (425) 761-6499,
4257616499, 425.761.6499 — all → <REDACTED:phone-us>
FICTIONAL 555-01XX (pass through):
555-0142, 555-0100, (555) 012-3456, (555) 0123456, +1 555-012-3456,
+1 (555) 012-3456, 5550123456, 555.012.3456 — all preserved
OTHER PII (redact): emails, ZIP+4, Mailchimp inbox-id — all redact
ALLOWLIST (pass through): @example.com, @example.net, @example.invalid
HARD-FAIL (refuse to write): lin_api_*, {32-hex}-us\d{1,2} — all hard-fail
Comment + code update explain the Perl variable-lifetime trap so future
maintainers don't accidentally re-introduce it.
* fix(skills): preserve scrub-failed body files; reconcile inline-bodies principle with --body-file (Greptile P2 on #1595)
The retro skill's first scrub-body pass had two follow-up gaps Greptile flagged
on the re-review of d4d4c27f:
1. **The cleanup destroyed the recovery path it advertised.** The
`scrub-failed` failure message tells the agent the body file is "left at
$BODY_TMP for hand-redaction" — but the unconditional `rm -rf
"$ISSUE_TMPDIR"` at the end of the loop wiped the file before the agent
could read it. The recovery message was a lie.
Fix: cleanup is now conditional on the scrub-failed count. If any WUs
failed scrub, the temp dir survives so the body file is hand-redactable;
the loop emits a NOTE to stderr telling the user where the surviving
files are. The dir lives in `mktemp -d` so it self-cleans on the next
`tmpwatch` / OS reboot regardless. Clean runs (no scrub-failed) wipe the
dir as before.
2. **The "Execution principles" block forbade `--body-file`** because in
the original design body construction via the `Write` tool added a
per-WU tool round-trip — the single largest source of perceived
latency. The new scrub_body step writes to a bash-resident temp file
inside one `Bash` invocation, scrubs, then passes the scrubbed file to
`gh --body-file`. There are no extra tool round-trips; the principle's
*intent* (avoid Write-tool calls per issue) is still honored, but the
wording ("never pass --body-file") read as forbidding the new approach.
Fix: update the principle to specifically allow bash-resident temp files
while still forbidding per-WU Write-tool round-trips. Adds a one-line
explanation that `--body-file` is the natural consumer of the scrub
step's file output.
Both fixes flow from Greptile's framing: "the hard-fail protection works,
but the recovery path it advertises is broken" and "the stale execution
principle contradicts the new --body-file approach." Confidence on the new
shape should land at 4-5/5 after the re-review.
---
skills/printing-press-retro/SKILL.md | 42 ++++
.../references/issue-template.md | 77 ++++++-
.../references/secret-scrubbing.md | 221 ++++++++++++++++++++-
3 files changed, 328 insertions(+), 12 deletions(-)
diff --git a/skills/printing-press-retro/SKILL.md b/skills/printing-press-retro/SKILL.md
index 2f2e62b0..e47e6c5f 100644
--- a/skills/printing-press-retro/SKILL.md
+++ b/skills/printing-press-retro/SKILL.md
@@ -67,6 +67,7 @@ different PRs.
## Cardinal rules
+- **Issue bodies and retro docs are public surfaces. Redact every real secret and PII before quoting.** Manuscripts contain credentials, account identifiers, real emails, and live API response data — that's why `references/secret-scrubbing.md` scrubs them before artifact upload. **Issue body text goes straight to a public GitHub issue, and the retro doc itself is preserved in manuscript proofs and may be uploaded as a zip.** When you quote scanner output, dogfood payloads, Greptile review comments, or API response bodies as "evidence," replace the sensitive substring with `<REDACTED:<kind>>` BEFORE pasting. This applies hardest to findings *about* secret/PII leaks: the natural impulse is to quote the actual leaked value to prove the leak exists — that re-leaks it in a public issue. Phase 5 (retro doc write) and Phase 6 (pre-post scrub) enforce this mechanically; this rule is the human-readable charter behind the mechanical enforcement. See [`references/secret-scrubbing.md`](references/secret-scrubbing.md) "Layer 0" for the redaction patterns and substitution shapes.
- **Default is "don't change the machine."** The Printing Press is mature — 30+ CLIs printed, most templates exercised across many shapes. The burden of proof is on the finding, not on the Skip path. Most things you encountered while printing one CLI are that CLI's quirks, iteration noise, or upstream API behavior — not generator gaps. Propose a machine change only when cross-CLI evidence is concrete and the finding survives the Phase 3 adversarial check (Step G).
- **A retro of three sharp findings is more valuable than ten mixed-quality findings.** Each filed finding spends maintainer attention. If you find yourself writing "every finding warrants action" or producing zero drops and zero skips, stop and re-triage — that outcome is the failure mode this skill exists to prevent.
- The retro proposes Printing Press changes that help multiple printed CLIs. Don't propose direct edits to the one CLI that just shipped, and don't propose machine changes whose value is unique to this CLI's quirks — those are printed-CLI fixes wearing a generator costume.
@@ -667,6 +668,38 @@ Write the full retro document to `$RETRO_PROOF_PATH`, then copy that file to
`$RETRO_SCRATCH_PATH`. This must complete before Phase 6 Step 1 copies the
manuscripts directory to staging.
+### Scrub the retro doc immediately after writing
+
+The retro doc is preserved in `manuscripts/<api>/<run>/proofs/` (durable),
+copied to `/tmp/printing-press/retro/` (scratch), and read by future runs'
+Phase 3 Step D dedup scan. If a finding's "What we observed" block pasted
+unredacted scanner output, dogfood payloads, or Greptile review comments, the
+secret/PII propagates into all three locations. Run the Layer 0 body scrub
+from `references/secret-scrubbing.md` immediately after writing the doc, so
+the scrubbed version becomes canonical:
+
+```bash
+# Define scrub_body once at the top of the Phase 5/6 bash blocks (full source
+# in references/secret-scrubbing.md Layer 0). Then:
+RETRO_PROOF_PATH_SCRUBBED="${RETRO_PROOF_PATH}.scrubbed.md"
+if ! scrub_body "$RETRO_PROOF_PATH" "$RETRO_PROOF_PATH_SCRUBBED"; then
+ echo "" >&2
+ echo "ERROR: retro doc contains an unredacted vendor-prefix secret." >&2
+ echo "Open $RETRO_PROOF_PATH, redact each match reported above using" >&2
+ echo " <REDACTED:<vendor>-<kind>:<first4>...<last4>:<len>ch>" >&2
+ echo "per references/secret-scrubbing.md Layer 0, then re-run /printing-press-retro." >&2
+ exit 1
+fi
+mv "$RETRO_PROOF_PATH_SCRUBBED" "$RETRO_PROOF_PATH"
+cp "$RETRO_PROOF_PATH" "$RETRO_SCRATCH_PATH"
+```
+
+Hard-fail behavior is intentional: vendor-prefix secrets are unrecoverable
+leaks once a retro doc gets archived or uploaded. The agent must hand-redact
+and re-run rather than silently shipping the leak. PII patterns (real emails,
+phones, account inboxes) auto-redact in place because the substitution is
+lossless for the retro's purpose.
+
## Phase 5.5: Plannable work units
Group related findings into coherent work units a planner could pick up directly.
@@ -935,3 +968,12 @@ Run artifact-packaging.md Step 7 to delete `$STAGING_DIR`.
fix without the original conversation.
- Do not add more phases, documents, or gates to the main printing-press skill.
Propose making existing phases smarter or the Printing Press emit better defaults.
+- **Never quote a leaked secret as "evidence" of a secret-leak finding.** The
+ finding's whole point is that the value should not be public; quoting it in
+ a public GitHub issue re-leaks it. Use the redacted form from
+ [`references/secret-scrubbing.md`](references/secret-scrubbing.md) Layer 0
+ (`<REDACTED:<vendor>-<kind>:<first4>...<last4>:<len>ch>`) — the maintainer
+ can fix the scanner without seeing the value. Phase 5 (retro doc write) and
+ Phase 6 Step 3 (pre-post scrub) hard-fail when an unredacted vendor-prefix
+ token is detected; that's the floor, not a substitute for redacting at
+ write time.
diff --git a/skills/printing-press-retro/references/issue-template.md b/skills/printing-press-retro/references/issue-template.md
index 5640b6db..e1df6ec6 100644
--- a/skills/printing-press-retro/references/issue-template.md
+++ b/skills/printing-press-retro/references/issue-template.md
@@ -46,10 +46,14 @@ issue is an action surface. Optimize the issue path for speed and signal:
to fix it. No priority prefix (`[P1]`), no WU ordinal (`WU-1`) — both
belong on labels and in the user-facing summary, not in the title that
someone scans across retros.
-- **Generate bodies inline, never via the Write tool.** Use shell heredocs
- into variables in a single `Bash` invocation. Writing each body to a file
- and passing `--body-file` adds a tool round-trip per issue and is the
- single largest source of perceived latency the skill historically had.
+- **Build bodies inline; never via the Write tool.** Use shell heredocs
+ into variables (or `printf` into bash-resident temp files inside a single
+ `Bash` invocation). Per-WU `Write` tool round-trips are the single largest
+ source of perceived latency the skill historically had. *Bash-resident
+ temp files passed to `gh ... --body-file` are fine* — they're written and
+ consumed inside one Bash call with no tool round-trip and unblock the
+ Phase 6 Step 3 scrub step (`scrub_body` needs a file input/output, and
+ `gh --body-file` is the natural consumer of the scrubbed output).
- **Run issue creates and comments in parallel.** Each WU's filing is
independent of every other WU. Background subshells writing to indexed
temp files, then `wait`, then read in order — the pattern is in Step 3.
@@ -187,6 +191,13 @@ user sees what will happen and can override before anything is filed.
For each WU, build either an issue body or a comment body, then run `gh`
in a background subshell. `wait`, then collect.
+**Layer 0 body scrub is mandatory.** Before posting any body to GitHub, the
+parallel loop runs `scrub_body` on the body file. Source the function from
+[`secret-scrubbing.md`](secret-scrubbing.md) "Layer 0" at the top of the
+bash block that contains the loop — paste the `scrub_body() { ... }`
+definition verbatim, or `source` an equivalent shell fragment. Without this,
+the loop will fail with `scrub_body: command not found` per-WU.
+
### Issue title (for new issues)
Succinct, problem-stated. Examples:
@@ -227,6 +238,17 @@ attachments. This is the meat of the body — be specific.>
- Spec snippet showing the trigger condition
- Error messages, stack traces, or scorer output verbatim where relevant
+> ⚠️ **Redact secrets and PII before pasting.** Issue bodies are public. When
+> the evidence comes from scanner output, dogfood payloads, Greptile review
+> comments, or live API responses, replace credentials with
+> `<REDACTED:<vendor>-<kind>:<first4>...<last4>:<len>ch>` and PII with
+> `<REDACTED:<kind>>` per [`secret-scrubbing.md`](secret-scrubbing.md)
+> "Layer 0". The Phase 6 Step 3 loop runs `scrub_body` on this file and will
+> hard-fail the WU's filing if it finds an unredacted vendor-prefix token —
+> that's the floor, not a substitute for redacting at write time.
+> **This applies most strictly to findings about secret/PII leaks**: quoting
+> the actual leaked value to prove the leak exists re-leaks it.
+
## Suspected root cause
<Hypothesis with explicit confidence level. Don't manufacture certainty.>
@@ -360,11 +382,33 @@ for wu_idx in "${!SORTED_WORK_UNITS[@]}"; do
URL=""
FAIL_MSG=""
+ # Layer 0 body scrub — runs once per WU on whichever body is about to be
+ # posted. scrub_body is defined in references/secret-scrubbing.md and must
+ # be sourced or pasted in the bash block enclosing this loop.
+ #
+ # Hard-fail behavior: if the body contains an unredacted vendor-prefix
+ # token (lin_api_, sk_live_, ghp_, etc.) we refuse to post that WU and
+ # surface it in $FAILED_ISSUES so the agent can hand-redact and retry.
+ # PII patterns auto-redact in place. The original $WU_BODY /
+ # $WU_COMMENT_BODY shell var is untouched; only the file passed to gh
+ # gets the scrubbed text.
+ BODY_TMP="$ISSUE_TMPDIR/body-$wu_idx.md"
+ BODY_TMP_SCRUBBED="$ISSUE_TMPDIR/body-$wu_idx.scrubbed.md"
if [[ "$DEDUP" == comment:* ]]; then
+ printf '%s' "$WU_COMMENT_BODY" > "$BODY_TMP"
+ else
+ printf '%s' "$WU_BODY" > "$BODY_TMP"
+ fi
+ if ! scrub_body "$BODY_TMP" "$BODY_TMP_SCRUBBED" 2>"$ISSUE_TMPDIR/scrub-$wu_idx.err"; then
+ KIND="scrub-failed"
+ SCRUB_REASON=$(tr '\n' ' ' < "$ISSUE_TMPDIR/scrub-$wu_idx.err" | head -c 400)
+ FAIL_MSG="$WU_TITLE — body scrub hard-failed (vendor-prefix secret in body); not posted. Reason: $SCRUB_REASON. Body left at $BODY_TMP for hand-redaction."
+ URL=""
+ elif [[ "$DEDUP" == comment:* ]]; then
ISSUE_NUM="${DEDUP#comment:}"
if URL=$(gh issue comment "$ISSUE_NUM" \
--repo "$REPO" \
- --body "$WU_COMMENT_BODY" 2>&1) \
+ --body-file "$BODY_TMP_SCRUBBED" 2>&1) \
&& [[ "$URL" == https://* ]]; then
KIND="commented"
else
@@ -376,7 +420,7 @@ for wu_idx in "${!SORTED_WORK_UNITS[@]}"; do
if URL=$(gh issue create \
--repo "$REPO" \
--title "$WU_TITLE" \
- --body "$WU_BODY" \
+ --body-file "$BODY_TMP_SCRUBBED" \
--label retro \
--label "priority:P${WU_PRIORITY_NUM}" \
--label "comp:${WU_COMP_SLUG}" 2>&1) \
@@ -425,14 +469,28 @@ for wu_idx in "${!SORTED_WORK_UNITS[@]}"; do
created|commented)
echo "${KIND^}: $URL"
;;
- create-failed|comment-failed)
+ create-failed|comment-failed|scrub-failed)
echo "WARNING: $FAIL_MSG"
FAILED_ISSUES+=("$FAIL_MSG")
;;
esac
done
-rm -rf "$ISSUE_TMPDIR"
+# Cleanup is conditional on scrub-failed WUs. Those WUs' body files are the
+# canonical hand-redaction source the failure message points at — wiping
+# $ISSUE_TMPDIR would destroy the recovery path. Keep the dir alive when any
+# WU scrub-failed; the agent (or user) can read the body file, hand-redact,
+# and re-run the affected WUs without re-deriving the body. The dir is in
+# $(mktemp -d) so it self-cleans on OS reboot / `tmpwatch` regardless.
+SCRUB_FAILED_COUNT=0
+for KIND in "${OUTCOME_KIND[@]}"; do
+ [ "$KIND" = "scrub-failed" ] && SCRUB_FAILED_COUNT=$((SCRUB_FAILED_COUNT + 1))
+done
+if [ "$SCRUB_FAILED_COUNT" -eq 0 ]; then
+ rm -rf "$ISSUE_TMPDIR"
+else
+ echo "NOTE: $SCRUB_FAILED_COUNT WU body file(s) preserved at $ISSUE_TMPDIR for hand-redaction. Delete manually after retrying the affected WU(s)." >&2
+fi
```
Failure modes:
@@ -443,6 +501,7 @@ Failure modes:
| `commented` | Comment added to existing issue | Listed as "commented on #N" |
| `create-failed` | `gh issue create` returned no usable URL | `$FAILED_ISSUES` summary; manual filing instructions |
| `comment-failed` | `gh issue comment` failed | `$FAILED_ISSUES` summary; manual comment instructions |
+| `scrub-failed` | Body contained an unredacted vendor-prefix secret; `scrub_body` refused to write the scrubbed copy. Body file left at `$BODY_TMP` for hand-redaction | `$FAILED_ISSUES` summary; agent must hand-redact per `secret-scrubbing.md` Layer 0 and retry the WU |
## Variables expected
@@ -465,7 +524,7 @@ Failure modes:
| Variable | Contains |
|---|---|
-| `$OUTCOME_KIND` | Array, one per WU: `created` / `commented` / `create-failed` / `comment-failed` |
+| `$OUTCOME_KIND` | Array, one per WU: `created` / `commented` / `create-failed` / `comment-failed` / `scrub-failed` |
| `$OUTCOME_URL` | Array of issue/comment URLs (empty for failures) |
| `$FAILED_ISSUES` | Array of human-readable failure descriptions; empty if every WU succeeded |
diff --git a/skills/printing-press-retro/references/secret-scrubbing.md b/skills/printing-press-retro/references/secret-scrubbing.md
index 4c5c08e6..a6bd9146 100644
--- a/skills/printing-press-retro/references/secret-scrubbing.md
+++ b/skills/printing-press-retro/references/secret-scrubbing.md
@@ -1,8 +1,223 @@
# Secret & PII Scrubbing for Public Artifacts
-Read this file during Phase 6 before zipping and uploading artifacts to catbox.moe.
-All scrub operations work on the **temp staging copy**, never on the user's original
-manuscripts or library directories.
+Read this file during Phase 5 (when writing the retro doc) and during Phase 6
+(before zipping artifacts AND before posting issue bodies to GitHub). All scrub
+operations work on **temp copies** of the retro doc and issue bodies, never on
+the user's original manuscripts or library directories.
+
+## Layer 0: Single-file body scrub (retro doc + issue bodies)
+
+Use this layer when the input is a **single markdown file** (the retro doc at
+Phase 5, or each issue/comment body file at Phase 6 Step 3 before `gh issue
+create`/`gh issue comment`). It's the public-surface counterpart to Layers 1–4
+(which scrub directories of artifacts).
+
+**Why this layer exists.** Layers 1–4 scrub `$STAGING_MANUSCRIPTS` and
+`$STAGING_CLI_SOURCE` (the folders zipped and uploaded to catbox). They do
+**not** touch the retro doc itself or the issue body text passed to `gh issue
+create`. Without Layer 0, a finding's "What we observed" block can paste raw
+scanner output, dogfood payloads, or Greptile comments containing real secrets
+or PII straight to a public GitHub issue. The retro skill's cardinal rule
+("Issue bodies and retro docs are public surfaces") is the human-readable
+charter; this layer is the mechanical enforcement.
+
+### scrub_body — reusable shell function
+
+Define this once at the top of the Phase 5 / Phase 6 bash blocks, then call
+`scrub_body <input> <output>` to produce a scrubbed copy of a single markdown
+file. Returns exit codes that callers branch on.
+
+```bash
+# scrub_body <in-file> <out-file>
+#
+# Scans the input file for vendor-prefix tokens (HARD FAIL) and PII patterns
+# (auto-redact). Writes a scrubbed copy to <out-file>. Behavior:
+#
+# exit 0 — clean (no findings, or only PII auto-redacted)
+# exit 1 — vendor-prefix token detected; refuses to write output. Caller
+# must hand-redact before proceeding. The offending file path
+# and pattern name go to stderr.
+# exit 2 — write failure or invalid arguments
+#
+# Hard-fail rationale: vendor-prefix tokens (API keys, OAuth tokens, JWTs)
+# are unrecoverable leaks once posted. Auto-redacting them would let an agent
+# inadvertently strip a real key the maintainer needs to know about
+# (e.g., "the test fixture key on line 14 is real — rotate it"). Forcing the
+# agent to redact-and-acknowledge keeps the human (or upstream agent) in the
+# loop. PII (emails, phones, real names) is auto-redacted because the
+# replacement is lossless for the retro use case.
+scrub_body() {
+ local in="$1" out="$2"
+ if [ -z "$in" ] || [ -z "$out" ] || [ ! -f "$in" ]; then
+ echo "scrub_body: usage: scrub_body <in-file> <out-file>" >&2
+ return 2
+ fi
+
+ # Layer 0a: vendor-prefix HARD-FAIL patterns. Order: most-specific first.
+ # Mirrors the patterns in internal/artifacts/secrets.go and Layer 2 below,
+ # extended with vendor patterns whose key shape is unambiguous enough to
+ # anchor without high false-positive rates (Mailchimp's `-us\d{1,2}`
+ # datacenter suffix, Linear's `lin_api_` prefix, Anthropic's `sk-ant-api03-`
+ # prefix). Add new patterns here only when the shape is specific enough
+ # that the regex cannot match generic placeholder strings.
+ local VENDOR_PATTERNS=(
+ 'stripe-live-key|sk_live_[A-Za-z0-9]{20,}'
+ 'stripe-test-key|sk_test_[A-Za-z0-9]{20,}'
+ 'github-pat|ghp_[A-Za-z0-9]{36,}'
+ 'github-oauth|gho_[A-Za-z0-9]{36,}'
+ 'github-server|ghs_[A-Za-z0-9]{36,}'
+ 'slack-bot-token|xoxb-[A-Za-z0-9-]{20,}'
+ 'slack-user-token|xoxp-[A-Za-z0-9-]{20,}'
+ 'aws-access-key|\bAKIA[0-9A-Z]{16}\b'
+ 'openrouter-key|sk-or-v1-[A-Za-z0-9_-]{24,}'
+ 'anthropic-key|sk-ant-api03-[A-Za-z0-9_-]{40,}'
+ 'linear-key|\blin_api_[A-Za-z0-9_-]{32,}'
+ 'mailchimp-key|\b[a-f0-9]{32}-us[0-9]{1,2}\b'
+ 'jwt-token|\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}'
+ 'bearer-with-value|Bearer [A-Za-z0-9._~+/=-]{30,}'
+ )
+
+ local hard_fail=0
+ for entry in "${VENDOR_PATTERNS[@]}"; do
+ IFS='|' read -r name regex <<< "$entry"
+ if grep -qE "$regex" "$in" 2>/dev/null; then
+ # Surface the finding without quoting the value (the file path + pattern
+ # name is enough for the agent to locate and fix; printing the value to
+ # stderr defeats the whole point of refusing to write the output).
+ lines=$(grep -nE "$regex" "$in" 2>/dev/null | cut -d: -f1 | head -5 | tr '\n' ',' | sed 's/,$//')
+ echo "scrub_body: HARD FAIL — $name pattern matched in $in (lines: $lines)" >&2
+ hard_fail=1
+ fi
+ done
+ if [ "$hard_fail" -eq 1 ]; then
+ echo "scrub_body: refusing to write $out. Hand-redact the matches above with <REDACTED:<vendor>-<kind>:<first4>...<last4>:<len>ch> per references/secret-scrubbing.md Layer 0, then retry." >&2
+ return 1
+ fi
+
+ # Layer 0b: PII auto-redact patterns. Replaces in the written copy only;
+ # input file is untouched. Tags use the same <REDACTED:<kind>> shape as the
+ # rest of the scrubbing layers.
+ #
+ # Allowlist: RFC 2606 reserved example domains (example.com / example.net /
+ # example.org / example.invalid / *.test / *.localhost) and NANP fictional
+ # phone ranges (555-01XX) are PASS-THROUGH — they exist precisely to be safe
+ # in documentation. The email regex anchors the allowlist by excluding the
+ # reserved TLDs in the local-part match. The phone regex carves out the
+ # 555-01XX range explicitly.
+ cp "$in" "$out" 2>/dev/null || { echo "scrub_body: failed to copy $in -> $out" >&2; return 2; }
+
+ # Order matters: specific account-identifier patterns first, then generic
+ # email/phone/zip. Otherwise the generic `email` pattern would consume the
+ # mailchimp-inbox-id email-shaped string and tag it as `<REDACTED:email>`
+ # instead of `<REDACTED:mailchimp-inbox-id>` — both redact the leak, but
+ # the specific tag is more useful for diagnostics.
+
+ # Mailchimp inbox-id: us<dc>-<hex>-<hex>@inbound.mailchimp.com is the
+ # account-bound inbound mailbox identifier — leaking it exposes the
+ # account's identity. Hex segments vary by account; 8+ chars each keeps
+ # the false-positive rate near zero (the @inbound.mailchimp.com tail is
+ # the strongest discriminator).
+ perl -i -pe 's/\bus\d{1,2}-[a-f0-9]{8,}-[a-f0-9]{8,}\@inbound\.mailchimp\.com\b/<REDACTED:mailchimp-inbox-id>/g' "$out" 2>/dev/null
+
+ # Real email (excluding RFC 2606 allowlist). The exclusion uses negative
+ # lookbehind via perl since BSD/GNU sed regex flavors differ.
+ perl -i -pe 's/\b([A-Za-z0-9._%+-]+)@(?!example\.(?:com|net|org|invalid)\b|[^\s]*\.(?:test|localhost|example)\b)([A-Za-z0-9.-]+\.[A-Za-z]{2,})\b/<REDACTED:email>/g' "$out" 2>/dev/null
+
+ # NANP US phone (excluding 555-01XX fictional range). Matches 10-digit forms
+ # with or without dashes/parens/spaces. The 555-01XX exclusion can't use a
+ # single negative lookahead anchored at the start because the optional `(`
+ # and optional country-code prefix shift the area-code position — when the
+ # number is written `(555) 012-3456`, the position before the `(` is not
+ # where the `555` lives, so the lookahead never fires. Instead, capture the
+ # area code ($1) and exchange ($2) and decide inline via `/e`.
+ #
+ # Perl gotcha: $& and $1..$N are global variables that get rewritten by
+ # every successful regex match — including the inner `$e =~ /^01/` test
+ # inside the replacement callback. Snapshot $&, $1, $2 into lexical
+ # variables ($w, $a, $e) BEFORE running the inner regex, otherwise the
+ # carve-out path returns `"01"` (the inner match) instead of the whole
+ # original phone string.
+ perl -i -pe 's{(?<![0-9])(?:\+?1[-\s.]?)?\(?([2-9][0-9]{2})\)?[-\s.]?([0-9]{3})[-\s.]?[0-9]{4}(?![0-9])}{my $w=$&; my $a=$1; my $e=$2; ($a eq "555" && $e =~ /^01/) ? $w : "<REDACTED:phone-us>"}ge' "$out" 2>/dev/null
+
+ # ZIP+4 (5 digits, dash, 4 digits). Bare 5-digit ZIPs are too noisy to redact
+ # safely (false-positives on order IDs, line counts, etc.); ZIP+4 is the
+ # dashed form that's unambiguous.
+ perl -i -pe 's/\b\d{5}-\d{4}\b/<REDACTED:zip-plus-4>/g' "$out" 2>/dev/null
+
+ return 0
+}
+```
+
+### When to call scrub_body
+
+**Phase 5 — after writing the retro doc:**
+
+```bash
+# Write retro to $RETRO_PROOF_PATH and $RETRO_SCRATCH_PATH as before, then:
+RETRO_PROOF_PATH_SCRUBBED="${RETRO_PROOF_PATH}.scrubbed.md"
+if ! scrub_body "$RETRO_PROOF_PATH" "$RETRO_PROOF_PATH_SCRUBBED"; then
+ echo "ERROR: retro doc contains an unredacted vendor-prefix secret." >&2
+ echo "Open $RETRO_PROOF_PATH, redact the matches reported above per references/secret-scrubbing.md Layer 0, then re-run Phase 5." >&2
+ exit 1
+fi
+mv "$RETRO_PROOF_PATH_SCRUBBED" "$RETRO_PROOF_PATH"
+cp "$RETRO_PROOF_PATH" "$RETRO_SCRATCH_PATH"
+```
+
+The scrubbed retro doc becomes canonical. The original (with potential PII)
+existed in process memory only and is overwritten by the scrubbed version.
+
+**Phase 6 Step 3 — before `gh issue create` and `gh issue comment`:**
+
+```bash
+# Build the WU body as before to /tmp/wu1-body.md, then:
+if ! scrub_body /tmp/wu1-body.md /tmp/wu1-body-scrubbed.md; then
+ echo "ERROR: WU body contains an unredacted vendor-prefix secret. Cannot post." >&2
+ # Mark this WU as a failed action in $FAILED_ISSUES so Step 6 surfaces it.
+ FAILED_ISSUES+="WU-1 (issue create): scrub_body hard-failed; body file left at /tmp/wu1-body.md for manual redaction.\n"
+ continue
+fi
+gh issue create ... --body-file /tmp/wu1-body-scrubbed.md
+```
+
+Same pattern for `gh issue comment`. If `scrub_body` hard-fails, that WU's
+filing is skipped and reported in the final summary so the agent (or user)
+knows to manually redact and retry.
+
+### Redaction shape reference
+
+When the agent (or a maintainer reviewing the retro doc) needs to hand-redact
+a vendor-prefix value, use this format:
+
+```
+<REDACTED:<vendor>-<kind>:<first4>...<last4>:<len>ch>
+```
+
+Examples:
+
+| Original (anti-pattern) | Redacted (correct) |
+|---|---|
+| `lin_api_a1b2c3d4e5f6789abcdef0123456789abcdef0123456` | `<REDACTED:linear-api-key:lin_a...0456:48ch>` |
+| `22eb323ac258d2e04c3bf9ade77b9c78-us6` | `<REDACTED:mailchimp-api-key:22eb...-us6:36ch>` |
+| `ghp_abc123def456ghi789jkl012mno345pqr678stu90` | `<REDACTED:github-pat:ghp_a...tu90:40ch>` |
+| `sk_live_51AbCdEf123456...XyZ` | `<REDACTED:stripe-live-key:sk_li...XyZ:N-ch>` |
+
+The first4 + last4 + length fragment preserves enough shape information for a
+maintainer to recognize the vendor pattern (and to confirm the same key isn't
+recurring across multiple findings) without re-exposing the value. The
+`<kind>` tag is enough to drive a follow-up retro finding ("the scanner missed
+a `linear-api-key`") without quoting the value.
+
+For PII (`email`, `phone-us`, `zip-plus-4`, `mailchimp-inbox-id`, etc.),
+just the `<kind>` tag — no fragment — is the right shape. The maintainer
+doesn't need to identify which specific email; they need to know an email
+shape was leaked.
+
+### Things to never quote even in redacted form
+
+- **Full URLs containing tokens.** GitHub's secret-scanning "unblock-secret" URLs (e.g., `https://github.com/<org>/<repo>/security/secret-scanning/unblock-secret/<token-id>`) embed a token-id that is itself a credential GitHub uses to authorize the bypass. Even with the token-id redacted, the URL leaks the org/repo/PR triple that the secret-scanning event refers to. Replace the whole URL with `<REDACTED:gh-secret-scanning-url>`.
+- **Authorization-header values** in HAR / dogfood-full.json snippets — even when the value looks like a placeholder, treat it as live until proven otherwise. Replace the whole header line with `<REDACTED:authorization-header>`.
+- **Account-bound inbound mailboxes / webhook callback URLs / OAuth client-secret pairs.** These are not "secrets" in the API-key sense but identifying the account is a real privacy regression. Tag and redact.
## Layer 1: Exact-value scanning
← c3e1b87a feat(cli): emit .printing-press-patches.json on every genera
·
back to Cli Printing Press
·
docs(skills): publish skill aligns with library's bot-only g dde2a4e0 →