Document Redaction skill for Codex
Remove the text, not just paint over it. Codex redacts by deleting content from every layer of the file rather than drawing black boxes: it inventories scans, forms, attachments and old revisions, applies PyMuPDF redaction annotations, scrubs metadata, rewrites the file in full, then re-extracts the output and scans it with a bundled Luhn/IBAN-aware PII checker before handing it back with a redaction log.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- document-redaction Installs to ~/.agents/skills/document-redaction/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install document-redaction The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project document-redaction Installs to .agents/skills/document-redaction/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/document-redaction.zipunzip -o document-redaction.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) document-redaction PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $document-redaction, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Performs true redaction of PDFs, Word files and scans so removed text, images and metadata cannot be recovered, using PyMuPDF redaction annotations, metadata scrubbing and PII detection (regex with checksums, Presidio). Use when asked to redact, black out, anonymize or sanitize a PDF or .docx before sharing, to strip names, emails, account numbers or SSNs from documents, or to check whether an existing redacted file still leaks text underneath.
Use it when
- A PDF or .docx must be shared with personal or confidential data removed
- You need to prove an existing redacted file does not leak hidden text
- Scanned pages mixed with digital pages need consistent redaction
- Document metadata, comments or tracked changes may expose authors or drafts
Use something else when
- The scanned pages have no text layer yet and need OCR first: use ocr-documents
- You only need to pull fields out of invoices or forms: use form-data-extraction
How to check the result
Re-extract text from the output with two different tools, run the bundled PII scanner and the approved term list against it, confirm a single %%EOF and empty metadata, then visually spot-check rendered pages.
The mistake it prevents
Drawing black rectangles or saving the PDF incrementally leaves the original text in the file, where any copy-paste or extractor recovers it.
A redaction is done when the removed content is gone from every layer of the file (text, images, vector art, annotations, form fields, metadata, earlier revisions) and a fresh extraction of the output finds nothing. A black rectangle drawn on top is not a redaction.
Hard rules
- Never overwrite the original. Write to a new file and keep the source untouched.
- Never save a redacted PDF incrementally. Incremental updates append to the file, so the
original bytes stay recoverable after
%%EOF. - Never rely on drawing shapes, highlighting in black, setting text color to white, or covering with an image. All of these leave the text extractable.
- Treat the output as unverified until step 6 passes. Say so if you stop earlier.
- Redaction lists are sensitive. Do not echo the found values into chat or commit them; report counts and locations (page, field type) instead.
Workflow
- [ ] 1. Define scope: what to remove, for whom, which file types
- [ ] 2. Inspect the files (text layer? scans? forms? attachments? revisions?)
- [ ] 3. Detect candidates (explicit terms + PII patterns + review)
- [ ] 4. Apply true redaction
- [ ] 5. Scrub metadata and hidden content, rewrite the file
- [ ] 6. Verify by re-extracting and scanning the output
- [ ] 7. Write the redaction log
1. Define scope
Ask (or infer from the request) and write down:
- Categories: personal names, emails, phone numbers, postal addresses, government IDs, bank/card numbers, dates of birth, case numbers, faces/signatures in images.
- Exceptions: e.g. keep the company’s own name, keep the public officer’s name.
- Replacement style: solid box (default), box with label such as
[REDACTED: EMAIL], or pseudonym (Person A) for anonymized analysis copies. Pseudonyms must be consistent across the whole document set; keep the mapping table out of the output folder. - Legal basis, if any (FOIA exemption codes, GDPR article). Labels often must cite it.
2. Inspect before touching anything
Install note: pip install pymupdf needs network; ask for approval if the sandbox blocks it.
import pymupdf # PyMuPDF >= 1.24; older code uses "import fitz"
doc = pymupdf.open("in.pdf")
print("pages", doc.page_count, "encrypted", doc.is_encrypted)
print("metadata", doc.metadata)
print("embedded files", doc.embfile_names())
print("form fields", sum(1 for p in doc for _ in p.widgets()))
for p in doc:
text = p.get_text().strip()
print(p.number + 1, "chars", len(text), "images", len(p.get_images()),
"annots", len(list(p.annots())))
Decide from what you see:
| Finding | Consequence |
|---|---|
| Page has images but no text | Scanned page. OCR to locate text (see ocr-documents), then redact the image pixels at those boxes |
| Embedded files / attachments | Inspect and delete or redact them separately; doc.scrub() can remove them |
| Form fields (widgets) | Field values live outside page text; flatten or delete fields that hold PII |
Many %%EOF markers (grep -a -c %%EOF in.pdf) | Earlier revisions exist; a full rewrite in step 5 drops them |
| Encrypted | Need the password from the user; never try to crack it |
.docx input | See “Word documents” below before choosing a route |
3. Detect candidates
Combine three sources, then review:
-
Explicit terms supplied by the user (names, matter numbers). Search case-insensitively and include variants: initials, possessives, line-broken names, all-caps headers.
-
Pattern detection with validation, not bare regex. Card numbers must pass Luhn, IBANs must pass mod-97, SSNs must not start with 000/666/9xx. The bundled scanner does this on extracted text:
python3 scripts/scan_pii.py extracted.txt # human report python3 scripts/scan_pii.py --json extracted.txt # machine-readable, values masked -
NER for names and addresses when there is no term list: Microsoft Presidio (
presidio-analyzerplus a spaCy model; both need network to install). Treat its output as candidates with scores; names are where false negatives hide.
Produce a candidate table (page, category, masked value, source, decision) and get user sign-off on ambiguous entries before applying. Over-redaction of non-sensitive context is also a defect when the document must stay useful.
4. Apply true redaction (PDF)
import pymupdf
TERMS = ["Jane Q. Example", "ACCT-99812"] # from step 3, approved list
doc = pymupdf.open("in.pdf")
hits = 0
for page in doc:
for term in TERMS:
for rect in page.search_for(term): # list of Rect; case-insensitive
page.add_redact_annot(rect, fill=(0, 0, 0))
hits += 1
# images=PDF_REDACT_IMAGE_PIXELS blanks only the overlapped pixels of images;
# the default removes any image that touches a redaction box entirely.
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_PIXELS)
print("redacted areas:", hits)
Notes:
search_formatches on one line; a phrase broken across lines returns nothing. Search for each part as well, or build rects frompage.get_text("words")sequences.- For labelled boxes pass
text="[REDACTED]",fontsize=8,fill=(0,0,0)andtext_color=(1,1,1)toadd_redact_annot; check the label fits the box. - For regions (signatures, photos, stamps) pass a
pymupdf.Rect(x0, y0, x1, y1)in points from the top-left; derive it frompage.get_image_rects(xref)or OCR boxes. - Vector-drawn text (outlined fonts) is not found by search. If the source is outlined, redact by region and verify visually.
- Redacting form fields: delete the widget (
page.delete_widget(w)) when its value is sensitive; redaction annots do not clear AcroForm values.
5. Scrub and rewrite
doc.set_metadata({}) # title, author, subject, keywords, producer...
doc.del_xml_metadata() # XMP packet
doc.scrub(attached_files=True, clean_pages=True, embedded_files=True,
hidden_text=True, javascript=True, metadata=True, redactions=True,
remove_links=True, reset_fields=True, thumbnails=True, xml_metadata=True)
doc.save("out.pdf", garbage=4, deflate=True, clean=True) # full rewrite, never incremental=True
scrub() is aggressive (it removes links and resets fields). If the recipient needs links
or form fields, pass False for those flags and handle them by hand.
For maximum assurance (court filings, leaked-risk documents) rasterize the redacted pages to images at 200 to 300 dpi and rebuild the PDF; then OCR if the recipient needs search. This destroys hidden text, fonts and structure, and also accessibility, so offer it as a choice rather than a default.
Word documents (.docx)
Content hides in places a visible edit misses: tracked deletions (w:del), comments,
headers/footers, footnotes, text boxes, alt text on images, hidden text (w:vanish),
document properties (docProps/core.xml, docProps/app.xml, docProps/custom.xml),
embedded objects, and the file name itself.
Recommended route:
- Accept or reject all tracked changes and delete comments first (see word-tracked-changes).
- Replace sensitive runs with the label text in every part:
word/document.xml,header*.xml,footer*.xml,footnotes.xml,endnotes.xml,comments.xml. Note that one word can be split across severalw:rruns, so match on joined paragraph text, then rewrite the runs. - Clear core and custom properties; remove
docProps/thumbnail.*if present. - If the recipient does not need an editable file, convert to PDF and run the PDF workflow on the result. That is the safer default.
6. Verify
Run all of these against the output, never the input:
python3 -c "import pymupdf,sys; d=pymupdf.open(sys.argv[1]); print(''.join(p.get_text() for p in d))" out.pdf > out.txt
python3 scripts/scan_pii.py out.txt # expect: no findings
grep -i -c -f approved_terms.txt out.txt # expect: 0
pdftotext out.pdf - | grep -i "jane" # second extractor (poppler), if installed
grep -a -c "%%EOF" out.pdf # expect: 1
qpdf --check out.pdf # structural sanity, if installed
Also check: doc.metadata is empty, doc.embfile_names() is empty, page count unchanged,
and render two or three pages to PNG (page.get_pixmap(dpi=100).save(...)) to confirm the
boxes cover what they should and nothing more. For docx, unzip and grep -ril each term
across all XML parts.
If any check finds a term: do not patch the output. Fix the candidate list or the method and rerun from step 4 on the original.
Gotchas
- Copy-pasting from a PDF viewer is the classic leak test; extraction tools are stricter.
- Ligatures and kerning can split words (
fi, letter-spaced headings). Search for fragments and verify with the scanner. - Redacting a scanned page without OCR leaves the invisible OCR text layer if one exists.
- Bates numbers, headers and footers repeat on every page; one miss there leaks everywhere.
- Spreadsheets embedded in docx or PDF portfolios carry full data behind a picture.
- File names and email subjects often contain the very name you redacted.
Output: redaction log
## Redaction log: contract-2026-04.pdf -> contract-2026-04.redacted.pdf
- Method: PyMuPDF redaction annotations, images=PIXELS, metadata scrubbed, full rewrite
- Categories: person names (14), emails (6), IBAN (2), signature images (3)
- Pages affected: 1, 2, 5, 9-11
- Exceptions kept: company name, public registry numbers (per request)
- Verification: re-extracted text scanned (0 findings), term list grep (0), 1 %%EOF,
metadata empty, visual check of pages 1, 5, 10
- Residual risk: page 7 is a scan; redacted by OCR boxes, verified visually only What the zip contains
Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Document Redaction"
short_description: "Remove the text, not just paint over it"
default_prompt: "$document-redaction Redact every client name and account number in these three PDFs before I send them to opposing counsel" scripts/scan_pii.py
#!/usr/bin/env python3
"""Scan extracted document text for likely PII, with checksum validation.
Usage:
python3 scan_pii.py FILE [FILE ...] # human-readable report
python3 scan_pii.py --json FILE # JSON report (values masked)
cat text.txt | python3 scan_pii.py - # read stdin
Exit codes: 0 = no findings, 1 = findings, 2 = usage or read error.
Detects: email, card number (Luhn), IBAN (mod-97), US SSN, phone number, IPv4.
Values are always masked in output so the report itself does not leak data.
"""
import argparse
import json
import re
import sys
EMAIL = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b")
CARD = re.compile(r"\b(?:\d[ -]?){12,18}\d\b")
IBAN = re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]){11,30}\b")
SSN = re.compile(r"\b(\d{3})-(\d{2})-(\d{4})\b")
PHONE = re.compile(r"(?<![\w-])(?:\+\d{1,3}[ .-]?)?(?:\(\d{2,4}\)[ .-]?)?\d{2,4}[ .-]\d{3,4}[ .-]\d{3,4}(?![\w-])")
IPV4 = re.compile(r"\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b")
def luhn_ok(digits: str) -> bool:
total = 0
for i, ch in enumerate(reversed(digits)):
n = int(ch)
if i % 2 == 1:
n *= 2
if n > 9:
n -= 9
total += n
return total % 10 == 0
def iban_ok(raw: str) -> bool:
s = raw.replace(" ", "")
if not 15 <= len(s) <= 34:
return False
rearranged = s[4:] + s[:4]
num = "".join(str(int(c, 36)) for c in rearranged)
return int(num) % 97 == 1
def ssn_ok(m: re.Match) -> bool:
area, group, serial = m.group(1), m.group(2), m.group(3)
return not (area in ("000", "666") or area.startswith("9") or group == "00" or serial == "0000")
def mask(value: str) -> str:
v = value.strip()
if len(v) <= 4:
return "*" * len(v)
return v[:2] + "*" * (len(v) - 4) + v[-2:]
def scan(text: str):
findings = []
lines = text.splitlines()
for lineno, line in enumerate(lines, 1):
taken = [] # spans already claimed, so a card is not also reported as a phone
def add(kind, m, report=True):
span = m.span()
if any(s < span[1] and span[0] < e for s, e in taken):
return
taken.append(span)
if report:
findings.append({"line": lineno, "type": kind, "value": mask(m.group(0))})
for m in EMAIL.finditer(line):
add("email", m)
for m in IBAN.finditer(line):
if iban_ok(m.group(0)):
add("iban", m)
for m in CARD.finditer(line):
digits = re.sub(r"\D", "", m.group(0))
if 13 <= len(digits) <= 19:
# long digit runs that fail Luhn are claimed silently so they are
# not re-reported as phone numbers
add("card_number", m, report=luhn_ok(digits))
for m in SSN.finditer(line):
if ssn_ok(m):
add("us_ssn", m)
for m in IPV4.finditer(line):
add("ipv4", m)
for m in PHONE.finditer(line):
if len(re.sub(r"\D", "", m.group(0))) >= 9:
add("phone", m)
return findings
def main() -> int:
ap = argparse.ArgumentParser(description="Scan text for likely PII (values masked).")
ap.add_argument("files", nargs="+", help="text files to scan, or - for stdin")
ap.add_argument("--json", action="store_true", help="emit JSON")
args = ap.parse_args()
report = {}
for path in args.files:
try:
text = sys.stdin.read() if path == "-" else open(path, encoding="utf-8", errors="replace").read()
except OSError as exc:
print(f"error: cannot read {path}: {exc}", file=sys.stderr)
return 2
report[path] = scan(text)
total = sum(len(v) for v in report.values())
if args.json:
print(json.dumps({"total": total, "files": report}, indent=2))
else:
for path, items in report.items():
print(f"{path}: {len(items)} finding(s)")
for f in items:
print(f" line {f['line']:>5} {f['type']:<12} {f['value']}")
print(f"TOTAL: {total} finding(s)" if total else "OK: no PII patterns found")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.