Skip to main content

openadapt-privacy

Build Status PyPI version Python 3.10+ License: MIT

Finds and removes personal and health data from GUI automation artifacts: the text a recording captured, the element trees around it, and the screenshots. Presidio does the detection; this package wires it to the shapes a recording actually has.

A demonstration recording contains everything that was on screen and everything that was typed. So before you store one, share one, or export one, something has to strip the identifiers out, and that something needs to fail loudly when it can't. That's this.

Documentation · openadapt-flow · Repository lifecycle

Install

pip install "openadapt-privacy[presidio]"
python -m spacy download en_core_web_sm

The Presidio provider takes only the preinstalled, allowlisted en_core_web_sm pipeline and never downloads a model at runtime. A missing model, an unsupported language, or an inconsistent configuration raises PrivacyModelUnavailable before analysis starts, rather than quietly scrubbing worse than you expected.

Git / CI scan ([scan])

A regex gate for git trees. Clinic CI calls it so an OHIP-shaped number, a chart id, an MRN, a UNC path, or an RDP hostname can't land in a commit. It doesn't scrub recordings. It does not claim clinical validation.

pip install "openadapt-privacy[scan]"
openadapt-privacy-scan
openadapt-privacy-scan --self-test
openadapt-privacy-scan --root /path/to/repo

[scan] adds no packages. The scanner is stdlib, so import openadapt_privacy.scan works on a bare pip install openadapt-privacy and does not load Presidio, spaCy, or Pillow. python -m openadapt_privacy.scan is the same CLI.

from pathlib import Path
from openadapt_privacy.scan import scan_tree, self_test

hits = scan_tree(Path("."))   # ["ohip-dashed\tfile.txt:3", ...]
self_test()                   # 0 ok, 1 a rule stayed silent

--self-test plants fixtures under /tmp and exits 1 if a rule does not fire. Matching OHIP examples are not stored in the library; they're built at runtime.

Forbidden directory names: recordings, captures, screenshots, retinology, .private. Forbidden suffixes include .rdp, .db, and common media (.png, .mp4, and the rest of the set in scan.py).

openadapt_privacy/gitleaks.toml and openadapt_privacy/phi-patterns.txt carry the same rules for gitleaks / git-secrets.

Read this before you rely on it

Scrubbing is one control inside a reviewed egress process. It is not a guarantee that an artifact is free of protected data, and the evidence behind it is synthetic, not clinical.

tests/test_phi_recall.py is the regression gate: 26 synthetic identifiers across names, contact details, financial identifiers, dates of birth, addresses, network identifiers, medical record numbers, member IDs, and provider licenses. It requires 26 out of 26, it pins the entity type each one must produce, and it also checks that ordinary operational UI text comes back untouched.

Detection is contextual, so the same value scrubs differently depending on what surrounds it. Every output below was produced by running this code on 2026-08-28, not written by hand. Some of it will surprise you:

>>> from openadapt_privacy.providers.presidio import PresidioScrubbingProvider
>>> s = PresidioScrubbingProvider()

>>> s.scrub_text("Patient John Smith, john.smith@example.com, 555-123-4567, SSN 923-45-6789")
'Patient <PERSON>, <EMAIL_ADDRESS>, <PHONE_NUMBER>, <ORGANIZATION> <US_SSN>'

>>> s.scrub_text("SSN: 923-45-6789")
'<ORGANIZATION>: <US_SSN>'

>>> s.scrub_text("Card 4111111111111111 on file")
'Card <CREDIT_CARD> on file'

>>> s.scrub_text("Card: 4532-1234-5678-9012")
'Card: <DATE_TIME>'

The last two are the interesting pair. Both card numbers get redacted, but only the first is labelled CREDIT_CARD, because only the first passes a Luhn check. An identifier that no recognizer can validate falls to whatever the spaCy model makes of it, which for a run of digits is usually DATE_TIME.

So: the redaction holds either way, and a validated identifier now carries its own type. An unvalidated one does not. Measure the placeholder names against your own data before you route on them.

For production egress: scrub a copy, verify every output file, and bind the human or policy approval to the verified artifact. A model that ran without error is not evidence that the artifact is clean.

Scrubbing text

from openadapt_privacy.providers.presidio import PresidioScrubbingProvider

scrubber = PresidioScrubbingProvider()
scrubber.scrub_text("Contact John Smith at john.smith@example.com or 555-123-4567")
# 'Contact <PERSON> at <EMAIL_ADDRESS> or <PHONE_NUMBER>'

Scrubbing nested dicts

For element trees and event payloads:

from openadapt_privacy import scrub_dict
from openadapt_privacy.providers.presidio import PresidioScrubbingProvider

scrubber = PresidioScrubbingProvider()
scrub_dict(
    {
        "title": "User Profile - John Smith",
        "tooltip": "Click to contact john@example.com",
        "value": "Call 555-123-4567",
        "coordinates": {"x": 100, "y": 200},
    },
    scrubber,
)
{
    "title": "User Profile - <PERSON>",
    "tooltip": "Click to contact <EMAIL_ADDRESS>",
    "value": "Call <PHONE_NUMBER>",
    "coordinates": {"x": 100, "y": 200}
}

Only the keys in PrivacyConfig.SCRUB_KEYS_HTML are scrubbed, and non-string values pass through, which is why the coordinates survive. Pass scrub_all=True to scrub every string regardless of key.

The keys in PrivacyConfig.SCRUB_KEYS_SEPARATED (text and canonical_text) can also hold recorded keystrokes: one typed character per ACTION_TEXT_SEP, as in j-o-h-n-@-e-x-a-m-p-l-e-.-c-o-m. Those are reassembled before analysis, scrubbed, and separated again, because the PII is invisible in the split form. Whether that happens is decided by the value, not the key, so prose under text is scrubbed as prose:

scrub_dict({"text": "Email: john@example.com"}, scrubber)
# {'text': '<PERSON>: <EMAIL_ADDRESS>'}

scrub_dict({"text": "-".join("john@example.com")}, scrubber)
# {'text': '<-E-M-A-I-L-_-A-D-D-R-E-S-S->'}

Pass separated_keys=[] to turn keystroke handling off for a call, or separated_keys=["keys"] to move it to your own field name.

Scrubbing screenshots

from PIL import Image
from openadapt_privacy.providers.presidio import PresidioScrubbingProvider

scrubber = PresidioScrubbingProvider()
scrubbed = scrubber.scrub_image(Image.open("screenshot.png"))
scrubbed.save("screenshot_scrubbed.png")

Original screenshot with PII Scrubbed screenshot with PII redacted

OCR finds the text regions, the analyzer classifies them, and the detected regions get filled with a solid colour (SCRUB_FILL_COLOR, red by default). Anything OCR misses is not redacted.

Whole recordings

DictRecordingLoader takes a recording as a dict and scrubs the task description and every action together:

from openadapt_privacy import DictRecordingLoader
from openadapt_privacy.providers.presidio import PresidioScrubbingProvider

recording = DictRecordingLoader().load_from_dict({
    "task_description": "Send email to John Smith at john@example.com",
    "actions": [
        {"id": 1, "action_type": "click", "text": "Compose", "timestamp": 1000},
        {"id": 2, "action_type": "click", "text": "Send",
         "window_title": "Email to john@example.com", "timestamp": 3000},
    ],
})
scrubbed = recording.scrub(PresidioScrubbingProvider())

Subclass RecordingLoader for your own storage. Implement load and save, and you get load_and_scrub for free:

from openadapt_privacy import RecordingLoader, Recording

class SQLiteRecordingLoader(RecordingLoader):
    def load(self, recording_id: str) -> Recording: ...
    def save(self, recording: Recording, recording_id: str) -> None: ...

Configuration

from openadapt_privacy.config import PrivacyConfig

PrivacyConfig(
    SCRUB_CHAR="X",                                  # for scrub_text_all
    SCRUB_FILL_COLOR=0xFF0000,                       # image redaction, BGR
    SCRUB_KEYS_HTML=["text", "value", "title", "tooltip"],
    SCRUB_PRESIDIO_IGNORE_ENTITIES=["DATE_TIME"],
)

The full field list is SCRUB_CHAR, SCRUB_LANGUAGE, SCRUB_FILL_COLOR, SCRUB_KEYS_HTML, SCRUB_KEYS_SEPARATED, ACTION_TEXT_NAME_PREFIX, ACTION_TEXT_NAME_SUFFIX, ACTION_TEXT_SEP, SCRUB_CONFIG_TRF, SCRUB_PRESIDIO_IGNORE_ENTITIES, and SPACY_MODEL_NAME.

The analyzer's supported entity set comes from Presidio: CREDIT_CARD, CRYPTO, DATE_TIME, EMAIL_ADDRESS, IBAN_CODE, IP_ADDRESS, LOCATION, MAC_ADDRESS, MEDICAL_LICENSE, NRP, PERSON, PHONE_NUMBER, UK_NHS, URL, US_BANK_NUMBER, US_DRIVER_LICENSE, US_ITIN, US_PASSPORT, US_SSN, plus ORGANIZATION from the spaCy pipeline. Which one fires on a given string depends on the text around it, so measure rather than assume.

Layout

openadapt_privacy/
├── scan.py           # git/CI regex gate (stdlib; no Presidio)
├── gitleaks.toml     # same rules for gitleaks
├── phi-patterns.txt  # same rules for git-secrets
├── base.py           # ScrubbingProvider, TextScrubbingMixin
├── config.py         # PrivacyConfig
├── loaders.py        # Recording, Action, Screenshot, RecordingLoader
├── providers/
│   └── presidio.py   # PresidioScrubbingProvider
└── pipelines/
    └── dicts.py      # scrub_dict, scrub_list_dicts

License

MIT

Metadata

Release files for openadapt-privacy 1.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for openadapt-privacy 1.1.0
File Size Uploaded
openadapt_privacy-1.1.0.tar.gz 77.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for openadapt-privacy 1.1.0
File Interpreter ABI Platform
openadapt_privacy-1.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 108.7 kB

Release files / openadapt_privacy-1.1.0.tar.gz

Download URL openadapt_privacy-1.1.0.tar.gz
Size 77.8 kB
Tags Source
SHA-256 checksum
How to use checksums
9976c844e8b9b74c13e6f685030e7013e27bc7eb833d06884bfa5e018af74f65
BLAKE2b-256 checksum
How to use checksums
992aeb49e6058d3c8c173ce4eee9875fae1ddedf108b2936e62c461e60e8c76e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release files / openadapt_privacy-1.1.0-py3-none-any.whl

Download URL openadapt_privacy-1.1.0-py3-none-any.whl
Size 30.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ca4533ff99e754d14e50de6f6be8d0ce308892f90935e3f59f5f521104a29d7f
BLAKE2b-256 checksum
How to use checksums
7f82e34f256f3e010eaa53514fcea1c74eb1e0b149ec0cb6f3e92bc52b2940fd
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 1, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.1.0 This release

2 release files

1.0.4

2 release files

1.0.3

2 release files

1.0.2

2 release files

1.0.1

2 release files

1.0.0

2 release files

0.1.1

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page