Documents & Files Editor's pick

Document Redaction skill for Claude Code

Remove the data, not just hide it under a box. Black boxes over live text are how redacted court filings leak. With this skill Claude deletes the underlying characters and image pixels using PyMuPDF, scrubs metadata, comments, tracked changes and attachments, and rewrites the file in full. A separate verifier searches two text extractors, raw bytes, fields and metadata, and every job ends with a redaction log.

  • namedocument-redaction
  • size179 lines · ≈2,150 tokens
  • files3
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Performs true redaction of personal and confidential data in PDFs, Word files, and scans — removing the underlying text, images, metadata, comments, tracked changes, and hidden layers rather than drawing black boxes — then proves it with an independent verification pass. Covers PII discovery, PyMuPDF redaction, DOCX cleanup, scanned pages, and a redaction log. Use when the user needs to redact, anonymize, or sanitize a document before sharing, responding to a records or discovery request, or publishing, or asks whether an existing redacted file is safe.

Try asking

  • Redact all employee names and SSNs from this HR report before we send it to the auditor
  • Is this redacted PDF actually safe? Check whether the text is still there
  • Remove client names from this Word contract so we can share it as a sample

Use it when

  • A document must be shared externally without personal or confidential data
  • Responding to records requests, discovery, or audits that require redactions
  • Checking whether an already redacted file still leaks text
  • Publishing documents that contain names, IDs, or signatures

Skip it when

  • You need to anonymize rows in a dataset or test database — use test-data-factories instead
  • The goal is general PDF editing like merging or form filling — use pdf-toolkit instead

How to check the result

The verifier passes on the output for the full term list and patterns across text, raw bytes, metadata and fields, and every page has been rendered and visually reviewed.

The common mistake it prevents

Drawing black rectangles or using black highlighting over text, which leaves every word selectable, searchable and copyable in the shared file.

~/.claude/skills/document-redaction/SKILL.md

--- name: document-redaction description: Performs true redaction of personal and confidential data in PDFs, Word files, and scans — removing the underlying text, images, metadata,… license: MIT ---

Done well means: the sensitive values cannot be recovered from the output by copying text, searching, extracting, reading metadata, undoing edits, or inspecting raw bytes; a second, independent tool confirms it; and the user receives a log of what was removed and what still needs human review. A black rectangle over live text is not a redaction.

Hard rules

  • Never redact in place. Work on a copy; keep the original untouched and access-controlled.
  • Never “redact” by drawing shapes, highlighting in black, changing font color to white, or covering with images. The text stays in the file and is selectable.
  • Never treat automated PII detection as complete. Pattern matching misses names, addresses, and context-dependent identifiers; a human must review the result.
  • Do not upload sensitive documents to third-party services (online PDF tools, cloud OCR) without explicit user approval.
  • Flatten, do not hide: comments, tracked changes, hidden rows/sheets, layers, and form values are all still in the file until removed.
  • Agree the redaction scope with the user (which categories, which exemptions, label text) before starting; legal contexts often require reason codes per redaction.

Workflow

- [ ] 1. Inventory the file: type, text layer vs scanned, metadata, comments, attachments
- [ ] 2. Build the target list: literal terms + patterns + regions, confirmed with the user
- [ ] 3. Dry run: list every match with page and position
- [ ] 4. Redact: remove content under each region, then scrub document-level data
- [ ] 5. Verify with an independent pass (text, raw bytes, metadata, annotations, fields)
- [ ] 6. Human visual review of every page, especially images and scans
- [ ] 7. Deliver output + redaction log; state residual risks

1. Inventory

For PDFs, run the inspector from pdf-toolkit or check quickly:

pdffonts input.pdf          # no fonts on a page = scanned image; search cannot find its text
pdfimages -list input.pdf   # images that may contain sensitive content (signatures, IDs, photos)

For DOCX, docx_inspect.py from docx-documents shows tracked changes, comments, and headers/footers. For XLSX, check hidden sheets, hidden rows/columns, comments, named ranges, and pivot caches (they keep copies of source data).

2. Targets

CategoryHow to find
Known names, account numbers, case IDsLiteral terms list from the user (names.txt)
Structured identifiersPatterns: SSN, email, phone, IBAN, card numbers, IPs, dates of birth
Free-text personal dataNamed-entity detection (spaCy, Microsoft Presidio) as a suggestion list only, reviewed by a human
Signatures, photos, ID scans, handwritingVisual review; redact by region
Indirect identifiersJob title + small team + date can identify someone; discuss with the user

Include variants: Jane Doe, J. Doe, Doe, Jane, JANE DOE, email local parts, initials in filenames, and values split across lines.

3–4. Redact PDFs (PyMuPDF)

pip install pymupdf pypdf
python3 scripts/redact_pdf.py input.pdf out.pdf --terms-file names.txt --pattern ssn --pattern email --dry-run
python3 scripts/redact_pdf.py input.pdf out.pdf --terms-file names.txt --pattern ssn --pattern email --label "[REDACTED]"

The script searches each page for literal terms and built-in or custom regexes (ssn, email, phone, iban, card, ipv4, --regex), adds redaction annotations, applies them (which deletes the characters and blanks image pixels under each box), then scrubs metadata, XMP, attachments, embedded files, JavaScript, links, hidden text, form fields, and thumbnails, and saves with a full rewrite (garbage=4, never incremental). It refuses to overwrite the input, warns about pages without a text layer, and exits 1 when nothing matched.

The core API, for custom cases such as region-based redaction of a signature:

import pymupdf
doc = pymupdf.open("input.pdf")
page = doc[3]
page.add_redact_annot(pymupdf.Rect(72, 640, 300, 700), fill=(0, 0, 0))   # points, origin top-left
page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_PIXELS)             # removes text + image pixels
doc.scrub()                                                                # metadata, annots, attachments...
doc.save("out.pdf", garbage=4, deflate=True, clean=True)

Scanned pages: search cannot see image text. Either OCR first (see ocr-scans) to locate words and then redact those regions with image pixels removed, or redact regions manually. Afterwards, consider rasterizing redacted scanned pages so no OCR layer with the original text survives — and re-verify.

Alternatives: qpdf cannot redact. Acrobat Pro’s Redact + “Sanitize Document” is the standard GUI route when the user prefers it. Avoid tools that only add /Redact annotations without applying them.

4b. Redact Word documents

  1. Accept or reject all tracked changes and delete all comments first (Word: Review > Accept All, Delete All Comments; or LibreOffice). Deleted text inside <w:del> is still in the file otherwise.
  2. Replace sensitive text with the label using run-aware replacement (see docx-documents) in body, tables, headers, footers, footnotes, and text boxes.
  3. Remove document properties (author, last modified by, company, custom properties) and check images for sensitive content.
  4. When in doubt, deliver a PDF: redact in DOCX, export to PDF, then verify the PDF too. Word’s “Inspect Document” catches hidden content but is not a redaction tool.

5. Verify independently

python3 scripts/verify_redaction.py out.pdf --terms-file names.txt --pattern ssn --pattern email
python3 scripts/verify_redaction.py out.docx --terms-file names.txt

For PDFs it searches text from two extractors (pypdf and PyMuPDF when installed), content stream literals, raw and decompressed file bytes (catches leftovers from incremental saves), document info and XMP metadata, annotations (and flags unapplied /Redact annotations), form fields, attachments, and bookmarks, and lists pages without a text layer for visual review. For DOCX it scans every XML part including headers, footers, comments, footnotes, and tracked deletions. Exit 1 on any leak.

Also try what a recipient would: open the file, select all, copy, and paste into a text editor; search for a redacted term in a viewer.

6. Visual review

Render every page and look at it (Claude can read images):

pdftoppm -png -r 60 out.pdf review/page

Check that boxes cover the full value, nothing is half-covered, images/signatures/stamps are handled, and labels do not leak length or format where that matters (e.g. a fixed-width [REDACTED] is safer than boxes sized to each name, which can reveal name length).

7. Redaction log

# Redaction log: <document> -> <output file>
Date: 2026-03-02   Performed by: <name/agent>   Reviewed by: <name>
Scope: names in names.txt (14), SSNs, personal emails, signatures on p. 7 and 12

| Page | Item (category) | Method | Reason code |
|---|---|---|---|
| 1 | Employee name (person) | Text redaction | Privacy |
| 7 | Signature (image region) | Region redaction, pixels removed | Privacy |

Document-level: metadata removed, 3 comments removed, 1 attachment removed.
Verification: verify_redaction.py PASS (pypdf + PyMuPDF, raw bytes, metadata, fields).
Residual risk / needs human review: page 9 handwritten note (scanned), indirect
identifiers in the org chart on p. 4.

Gotchas

  • Incremental saves append changes; the original text remains in earlier revisions of the same file. Always do a full rewrite and check for multiple %%EOF markers.
  • Text search fails on text split across lines, hyphenated words, ligatures, or unusual font encodings. Use regex with whitespace tolerance, and review visually.
  • Applying redactions with images=PDF_REDACT_IMAGE_NONE leaves image content under the box intact; only skip pixel removal when the image is known to be safe.
  • Spreadsheets exported to PDF can carry hidden columns; unhide and review before export.
  • Redacting a value in one document does not redact it in derived files (thumbnails, previews, search indexes, email attachments). Mention this to the user.

Verification

  • verify_redaction.py passes on the final output with the full term list and patterns.
  • Every page rendered and visually reviewed; scanned pages and images explicitly checked.
  • The redaction log lists each item, method, and any residual risk requiring human sign-off.

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

scripts/redact_pdf.py 118 lines · 5.1 KB
#!/usr/bin/env python3
"""Truly redact text from a PDF (removes the underlying characters, not just covers them),
then scrub metadata, annotations, attachments, form fields, links, and hidden text.

Requires: pip install pymupdf   (AGPL-3.0; check licensing before embedding in a product)
Usage:
  python3 redact_pdf.py in.pdf out.pdf --term "Jane Doe" --term "ACME-4471"
  python3 redact_pdf.py in.pdf out.pdf --pattern ssn --pattern email --label "[REDACTED]"
  python3 redact_pdf.py in.pdf out.pdf --terms-file names.txt --pattern phone --dry-run

Built-in patterns: ssn, email, phone, iban, card, ipv4. Custom regex: --regex 'EMP-\\d{6}'.
Always run verify_redaction.py on the output afterwards.
Exit codes: 0 = redacted (or dry-run listed matches), 1 = nothing matched, 2 = error.
"""
import argparse
import re
import sys

try:
    import pymupdf
except ImportError:
    print("error: PyMuPDF is required: pip install pymupdf", file=sys.stderr)
    sys.exit(2)

PATTERNS = {
    "ssn": r"\b\d{3}-\d{2}-\d{4}\b",
    "email": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
    "phone": r"(?<!\w)(?:\+?\d{1,3}[ .-]?)?(?:\(\d{2,4}\)|\d{2,4})[ .-]?\d{3,4}[ .-]?\d{3,4}(?!\w)",
    "iban": r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b",
    "card": r"\b(?:\d[ -]?){13,19}\b",
    "ipv4": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
}


def find_rects(page, terms, regexes):
    hits = []
    for term in terms:
        for rect in page.search_for(term):
            hits.append((term, rect))
    if regexes:
        text = page.get_text("text")
        found = {m.group(0) for rx in regexes for m in rx.finditer(text)}
        for value in found:
            for rect in page.search_for(value):
                hits.append((value, rect))
    return hits


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("input")
    ap.add_argument("output")
    ap.add_argument("--term", action="append", default=[], help="literal text to redact (repeatable)")
    ap.add_argument("--terms-file", help="file with one literal term per line")
    ap.add_argument("--pattern", action="append", default=[], choices=sorted(PATTERNS))
    ap.add_argument("--regex", action="append", default=[], help="custom regular expression (repeatable)")
    ap.add_argument("--label", default=None, help="replacement text drawn in the box, e.g. [REDACTED]")
    ap.add_argument("--keep-images", action="store_true", help="do not blank image pixels under redactions")
    ap.add_argument("--dry-run", action="store_true", help="list matches, write nothing")
    args = ap.parse_args()

    terms = list(args.term)
    if args.terms_file:
        terms += [t.strip() for t in open(args.terms_file, encoding="utf-8") if t.strip()]
    regexes = [re.compile(PATTERNS[p]) for p in args.pattern] + [re.compile(r) for r in args.regex]
    if not terms and not regexes:
        print("error: give at least one --term, --terms-file, --pattern or --regex", file=sys.stderr)
        return 2
    if args.input == args.output:
        print("error: write to a new file; never redact in place", file=sys.stderr)
        return 2

    try:
        doc = pymupdf.open(args.input)
    except Exception as exc:  # noqa: BLE001
        print(f"error: {exc}", file=sys.stderr)
        return 2
    if doc.needs_pass:
        print("error: PDF is encrypted; decrypt it first with the owner's password", file=sys.stderr)
        return 2

    total, image_only = 0, []
    for page in doc:
        if not page.get_text("text").strip():
            image_only.append(page.number + 1)
        hits = find_rects(page, terms, regexes)
        for value, rect in hits:
            total += 1
            if args.dry_run:
                print(f"  page {page.number + 1}: {value!r} at {tuple(round(v, 1) for v in rect)}")
            else:
                page.add_redact_annot(rect, text=args.label, fill=(0, 0, 0),
                                      text_color=(1, 1, 1) if args.label else None)
        if hits and not args.dry_run:
            page.apply_redactions(images=pymupdf.PDF_REDACT_IMAGE_NONE if args.keep_images
                                  else pymupdf.PDF_REDACT_IMAGE_PIXELS)

    if image_only:
        print(f"WARNING: pages without a text layer were not searched: {image_only[:30]} "
              "(OCR them or redact by region; scanned text cannot be found by search)")
    if args.dry_run:
        print(f"{total} match(es); nothing written (dry run)")
        return 0 if total else 1
    if total == 0:
        print("no matches found; output not written")
        return 1

    doc.scrub(metadata=True, xml_metadata=True, attached_files=True, embedded_files=True,
              javascript=True, hidden_text=True, remove_links=True, reset_fields=True,
              reset_responses=True, thumbnails=True, redactions=True)
    doc.save(args.output, garbage=4, deflate=True, clean=True)   # full rewrite, never incremental
    print(f"redacted {total} occurrence(s) -> {args.output}")
    print("next: python3 scripts/verify_redaction.py", args.output, "with the same terms/patterns")
    return 0


if __name__ == "__main__":
    sys.exit(main())
scripts/verify_redaction.py 182 lines · 7.4 KB
#!/usr/bin/env python3
"""Verify that sensitive values are really gone from a redacted PDF or DOCX.

Checks, for PDFs: extracted text on every page (two extractors when available), raw
decompressed content streams, metadata/XMP, annotations, form field values, attachments,
bookmarks, and pages without a text layer (which need visual review). For DOCX: every XML
part (body, headers, footers, comments, footnotes, tracked deletions, core properties).

Requires for PDF: pip install pypdf   (optional: pymupdf for a second extractor)
DOCX checks use the standard library only.

Usage:
  python3 verify_redaction.py redacted.pdf --term "Jane Doe" --pattern ssn --pattern email
  python3 verify_redaction.py redacted.docx --terms-file names.txt
Exit codes: 0 = no leaks found, 1 = leaks found, 2 = error.
"""
import argparse
import re
import sys
import zipfile
import zlib

PATTERNS = {
    "ssn": r"\b\d{3}-\d{2}-\d{4}\b",
    "email": r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b",
    "phone": r"(?<!\w)(?:\+?\d{1,3}[ .-]?)?(?:\(\d{2,4}\)|\d{2,4})[ .-]?\d{3,4}[ .-]?\d{3,4}(?!\w)",
    "iban": r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b",
    "card": r"\b(?:\d[ -]?){13,19}\b",
    "ipv4": r"\b(?:\d{1,3}\.){3}\d{1,3}\b",
}


def build_matchers(terms, patterns, regexes):
    out = []
    for t in terms:
        # tolerate whitespace/line-break differences in extracted text
        out.append((f"term {t!r}", re.compile(r"\s*".join(map(re.escape, t.split())), re.I)))
    for p in patterns:
        out.append((f"pattern {p}", re.compile(PATTERNS[p])))
    for r in regexes:
        out.append((f"regex {r}", re.compile(r)))
    return out


def scan(label, text, matchers, leaks):
    if not text:
        return
    for name, rx in matchers:
        for m in rx.finditer(text):
            leaks.append(f"{label}: {name} -> {m.group(0)[:60]!r}")


def verify_pdf(path, matchers, leaks, notes):
    try:
        from pypdf import PdfReader
    except ImportError:
        print("error: pypdf is required for PDFs: pip install pypdf", file=sys.stderr)
        sys.exit(2)
    reader = PdfReader(path)
    if reader.is_encrypted and reader.decrypt("") == 0:
        print("error: encrypted PDF; verify the unencrypted output", file=sys.stderr)
        sys.exit(2)
    for i, page in enumerate(reader.pages, start=1):
        text = page.extract_text() or ""
        if len(text.strip()) < 10:
            notes.append(f"page {i}: little or no text layer; check visually/OCR for unredacted image text")
        scan(f"page {i} text (pypdf)", text, matchers, leaks)
        contents = page.get_contents()
        if contents is not None:
            raw = contents.get_data().decode("latin-1", errors="ignore")
            literal = "".join(re.findall(r"\(((?:\\.|[^\\)])*)\)", raw))
            scan(f"page {i} content stream literals", literal, matchers, leaks)
        for annot in page.get("/Annots") or []:
            a = annot.get_object()
            for key in ("/Contents", "/T", "/RC", "/Subj"):
                if key in a:
                    scan(f"page {i} annotation {a.get('/Subtype')} {key}", str(a[key]), matchers, leaks)
            if a.get("/Subtype") == "/Redact":
                notes.append(f"page {i}: unapplied /Redact annotation present (redaction was marked, not applied)")
    meta = reader.metadata or {}
    scan("document info metadata", " ".join(f"{k}={v}" for k, v in meta.items()), matchers, leaks)
    root = reader.trailer["/Root"]
    if "/Metadata" in root:
        xmp = root["/Metadata"].get_object().get_data().decode("utf-8", errors="ignore")
        scan("XMP metadata", xmp, matchers, leaks)
    for name, f in (reader.get_fields() or {}).items():
        scan(f"form field {name}", f"{name} {f.get('/V', '')}", matchers, leaks)
    for name, blobs in reader.attachments.items():
        notes.append(f"attachment present: {name}")
        for blob in blobs:
            scan(f"attachment {name}", blob.decode("utf-8", errors="ignore"), matchers, leaks)

    def walk(items, depth=0):
        for it in items:
            if isinstance(it, list):
                walk(it, depth + 1)
            else:
                scan("bookmark", getattr(it, "title", str(it)), matchers, leaks)
    try:
        walk(reader.outline)
    except Exception:  # noqa: BLE001
        pass

    try:
        import pymupdf
        doc = pymupdf.open(path)
        for page in doc:
            scan(f"page {page.number + 1} text (pymupdf)", page.get_text("text"), matchers, leaks)
    except ImportError:
        notes.append("pymupdf not installed: second text extractor skipped")

    # Raw bytes: catches text in unreferenced objects left by incremental saves
    data = open(path, "rb").read()
    chunks = [data.decode("latin-1", errors="ignore")]
    for m in re.finditer(rb"stream\r?\n(.*?)\r?\nendstream", data, re.S):
        try:
            chunks.append(zlib.decompress(m.group(1)).decode("latin-1", errors="ignore"))
        except zlib.error:
            pass
    scan("raw file bytes / decompressed streams", "\n".join(chunks), matchers, leaks)
    if data.count(b"%%EOF") > 1:
        notes.append("multiple %%EOF markers: file was saved incrementally; earlier revisions may hold original text")


def verify_docx(path, matchers, leaks, notes):
    with zipfile.ZipFile(path) as z:
        for name in z.namelist():
            if not name.endswith((".xml", ".rels")):
                if name.startswith("word/media/"):
                    notes.append(f"image {name}: review visually for sensitive content")
                continue
            xml = z.read(name).decode("utf-8", errors="ignore")
            if "<w:del " in xml or "<w:del>" in xml:
                notes.append(f"{name}: contains tracked deletions (deleted text is still in the file)")
            text = re.sub(r"<[^>]+>", "", re.sub(r"</w:p>", "\n", xml))
            scan(name, text, matchers, leaks)


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("file")
    ap.add_argument("--term", action="append", default=[])
    ap.add_argument("--terms-file")
    ap.add_argument("--pattern", action="append", default=[], choices=sorted(PATTERNS))
    ap.add_argument("--regex", action="append", default=[])
    args = ap.parse_args()
    terms = list(args.term)
    if args.terms_file:
        terms += [t.strip() for t in open(args.terms_file, encoding="utf-8") if t.strip()]
    matchers = build_matchers(terms, args.pattern, args.regex)
    if not matchers:
        print("error: give at least one --term, --terms-file, --pattern or --regex", file=sys.stderr)
        return 2

    leaks, notes = [], []
    lower = args.file.lower()
    try:
        if lower.endswith(".pdf"):
            verify_pdf(args.file, matchers, leaks, notes)
        elif lower.endswith((".docx", ".docm")):
            verify_docx(args.file, matchers, leaks, notes)
        else:
            print("error: supported types are .pdf and .docx", file=sys.stderr)
            return 2
    except (OSError, zipfile.BadZipFile) as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    unique = list(dict.fromkeys(leaks))
    for n in dict.fromkeys(notes):
        print(f"NOTE: {n}")
    if unique:
        print(f"FAIL: {len(unique)} leak(s) found")
        for leak in unique[:100]:
            print(f"  - {leak}")
        return 1
    print("PASS: no matches for the given terms/patterns in any checked location")
    return 0


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc