From 5e87d7f8350a843f6d0d7fcec35b1e3d3f86de61 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 16:56:28 -0400 Subject: [PATCH 1/6] fix: fail closed before privacy scrubbing --- openadapt_privacy/base.py | 26 +++++++ openadapt_privacy/config.py | 14 +++- openadapt_privacy/loaders.py | 22 +++++- openadapt_privacy/providers/__init__.py | 11 +-- openadapt_privacy/providers/presidio.py | 16 ++++ tests/test_config.py | 9 +++ tests/test_no_silent_scrub_bypass.py | 98 ++++++++++++++++++++++--- tests/test_providers.py | 2 +- 8 files changed, 178 insertions(+), 20 deletions(-) diff --git a/openadapt_privacy/base.py b/openadapt_privacy/base.py index 73e23d6..0463f7a 100644 --- a/openadapt_privacy/base.py +++ b/openadapt_privacy/base.py @@ -7,6 +7,7 @@ from __future__ import annotations import importlib +from importlib import metadata from typing import Any, List from PIL import Image @@ -51,6 +52,31 @@ class ScrubbingProvider(BaseModel): model_config = {"arbitrary_types_allowed": True} + def validate_ready(self, modalities: list[str]) -> None: + """Fail before processing when this provider cannot scrub a modality.""" + unsupported = sorted(set(modalities) - set(self.capabilities)) + if unsupported: + raise ScrubbingProviderUnavailable( + f"Provider {self.name!r} cannot scrub required modalities {unsupported}; " + "no scrub was attempted." + ) + + def evidence(self, modalities: list[str]) -> dict[str, Any]: + """Return privacy-safe provenance for a completed scrub operation.""" + try: + package_version = metadata.version("openadapt-privacy") + except metadata.PackageNotFoundError: # pragma: no cover - source checkout only + package_version = "unknown" + return { + "schema_version": 1, + "provider": self.name, + "provider_class": f"{type(self).__module__}.{type(self).__qualname__}", + "package_version": package_version, + "policy_sha256": config.policy_digest(), + "modalities": sorted(set(modalities)), + "status": "completed", + } + def scrub_text(self, text: str, is_separated: bool = False) -> str: """Scrub PII/PHI from text. diff --git a/openadapt_privacy/config.py b/openadapt_privacy/config.py index b8d4228..447abff 100644 --- a/openadapt_privacy/config.py +++ b/openadapt_privacy/config.py @@ -7,7 +7,9 @@ from __future__ import annotations -from dataclasses import dataclass, field +import hashlib +import json +from dataclasses import asdict, dataclass, field from typing import Sequence @@ -76,6 +78,16 @@ class PrivacyConfig: # SpaCy model name SPACY_MODEL_NAME: str = "en_core_web_sm" + def policy_digest(self) -> str: + """Return a stable digest of the complete effective scrub policy.""" + payload = json.dumps( + asdict(self), + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + ).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + # Global default configuration instance config = PrivacyConfig() diff --git a/openadapt_privacy/loaders.py b/openadapt_privacy/loaders.py index 23bcfb0..8077d35 100644 --- a/openadapt_privacy/loaders.py +++ b/openadapt_privacy/loaders.py @@ -195,13 +195,31 @@ def scrub( Returns: New Recording instance with all content scrubbed. """ + required_modalities = ["TEXT"] + if scrub_images and self.screenshots: + required_modalities.append("PIL_IMAGE") + scrubber.validate_ready(required_modalities) + scrubbed_actions = [action.scrub(scrubber) for action in self.actions] scrubbed_screenshots = ( [screenshot.scrub(scrubber) for screenshot in self.screenshots] if scrub_images - else self.screenshots + else [ + Screenshot( + id=screenshot.id, + action_id=screenshot.action_id, + timestamp=screenshot.timestamp, + ) + for screenshot in self.screenshots + ] ) + scrubbed_metadata = scrubber.scrub_dict(self.metadata) if self.metadata else {} + scrubbed_metadata["_openadapt_privacy"] = { + **scrubber.evidence(required_modalities), + "omitted_modalities": [] if scrub_images else ["PIL_IMAGE"], + } + return Recording( id=self.id, task_description=( @@ -210,7 +228,7 @@ def scrub( timestamp=self.timestamp, actions=scrubbed_actions, screenshots=scrubbed_screenshots, - metadata=scrubber.scrub_dict(self.metadata) if self.metadata else {}, + metadata=scrubbed_metadata, ) def iter_actions_with_screenshots(self) -> Iterator[tuple[Action, Optional[Screenshot]]]: diff --git a/openadapt_privacy/providers/__init__.py b/openadapt_privacy/providers/__init__.py index 4719fcc..36b3f6e 100644 --- a/openadapt_privacy/providers/__init__.py +++ b/openadapt_privacy/providers/__init__.py @@ -35,12 +35,12 @@ def as_options(cls) -> dict[str, str]: @classmethod def get_available_providers(cls) -> list[str]: - """Return the list of available provider IDs. + """Return provider IDs implemented by this installed package. Returns: List of provider ID strings. """ - return [cls.PRESIDIO, cls.PRIVATE_AI] + return [cls.PRESIDIO] @classmethod def get_scrubber(cls, provider: str) -> "ScrubbingProvider": @@ -55,17 +55,14 @@ def get_scrubber(cls, provider: str) -> "ScrubbingProvider": Raises: ValueError: If the provider is not supported. """ - if provider not in cls.get_available_providers(): - raise ValueError(f"Provider {provider} is not supported.") - if provider == cls.PRESIDIO: from openadapt_privacy.providers.presidio import PresidioScrubbingProvider return PresidioScrubbingProvider() - elif provider == cls.PRIVATE_AI: + if provider == cls.PRIVATE_AI: raise NotImplementedError( "Private AI provider is not yet implemented in openadapt-privacy. " "Please use PRESIDIO or contribute an implementation." ) - raise ValueError(f"Unknown provider: {provider}") + raise ValueError(f"Provider {provider} is not supported.") diff --git a/openadapt_privacy/providers/presidio.py b/openadapt_privacy/providers/presidio.py index 52f211d..fc3bd04 100644 --- a/openadapt_privacy/providers/presidio.py +++ b/openadapt_privacy/providers/presidio.py @@ -206,6 +206,22 @@ class PresidioScrubbingProvider(ScrubbingProvider, TextScrubbingMixin): name: str = "PRESIDIO" capabilities: List[str] = [Modality.TEXT, Modality.PIL_IMAGE] + def validate_ready(self, modalities: list[str]) -> None: + """Validate every local dependency before any source content is read.""" + super().validate_ready(modalities) + _ensure_spacy_model() + try: + import presidio_analyzer # noqa: F401 + import presidio_anonymizer # noqa: F401 + + if Modality.PIL_IMAGE in modalities: + import presidio_image_redactor # noqa: F401 + except ImportError as exc: + raise PrivacyModelUnavailable( + "The configured Presidio scrubber is incomplete; no scrub was attempted. " + "Install it with: pip install 'openadapt-privacy[presidio]'" + ) from exc + def scrub_text(self, text: str, is_separated: bool = False) -> str: """Scrub PII/PHI from text using Presidio. diff --git a/tests/test_config.py b/tests/test_config.py index f3415ad..4a29d38 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -57,3 +57,12 @@ def test_ignore_entities_default_empty(self) -> None: """Test that SCRUB_PRESIDIO_IGNORE_ENTITIES defaults to empty.""" cfg = PrivacyConfig() assert list(cfg.SCRUB_PRESIDIO_IGNORE_ENTITIES) == [] + + +def test_policy_digest_is_stable_and_covers_effective_policy() -> None: + first = PrivacyConfig() + second = PrivacyConfig() + assert first.policy_digest() == second.policy_digest() + + second.SCRUB_KEYS_HTML.append("custom_sensitive_field") + assert first.policy_digest() != second.policy_digest() diff --git a/tests/test_no_silent_scrub_bypass.py b/tests/test_no_silent_scrub_bypass.py index 6a1eb05..e45fd3a 100644 --- a/tests/test_no_silent_scrub_bypass.py +++ b/tests/test_no_silent_scrub_bypass.py @@ -16,8 +16,13 @@ from PIL import Image import openadapt_privacy -from openadapt_privacy.base import Modality, ScrubbingProvider -from openadapt_privacy.loaders import Recording, Screenshot, UnscrubbedScreenshot +from openadapt_privacy.base import ( + Modality, + ScrubbingProvider, + ScrubbingProviderUnavailable, +) +from openadapt_privacy.config import config +from openadapt_privacy.loaders import Action, Recording, Screenshot, UnscrubbedScreenshot def _run_in_fresh_interpreter(source: str) -> subprocess.CompletedProcess[str]: @@ -39,12 +44,36 @@ class _StubScrubber(ScrubbingProvider): """Minimal image scrubber that proves redaction actually happened.""" name: str = "stub" - capabilities: list[str] = [Modality.PIL_IMAGE] + capabilities: list[str] = [Modality.TEXT, Modality.PIL_IMAGE] + + def scrub_text(self, text: str, is_separated: bool = False) -> str: + return text + + def scrub_dict(self, input_dict: dict) -> dict: + return input_dict.copy() def scrub_image(self, image: Image.Image, fill_color: int | None = None) -> Image.Image: return Image.new("RGB", image.size, (0, 0, 0)) +class _TextStubScrubber(ScrubbingProvider): + name: str = "text-stub" + capabilities: list[str] = [Modality.TEXT] + calls: int = 0 + + def scrub_text(self, text: str, is_separated: bool = False) -> str: + self.calls += 1 + return "" + + def scrub_dict(self, input_dict: dict) -> dict: + return {} + + +class _UnavailableTextScrubber(_TextStubScrubber): + def validate_ready(self, modalities: list[str]) -> None: + raise ScrubbingProviderUnavailable("dependency missing; no scrub was attempted") + + class TestPackageRootExports: """The documented consumer import must work on a complete install.""" @@ -105,9 +134,7 @@ def test_unloaded_image_with_path_raises(self, tmp_path) -> None: assert str(original) in str(excinfo.value) - def test_recording_scrub_raises_rather_than_returning_unscrubbed_paths( - self, tmp_path - ) -> None: + def test_recording_scrub_raises_rather_than_returning_unscrubbed_paths(self, tmp_path) -> None: original = tmp_path / "screenshot_001.png" Image.new("RGB", (4, 4), (255, 0, 0)).save(original) recording = Recording( @@ -122,9 +149,7 @@ def test_scrubbed_screenshot_does_not_name_the_original_file(self, tmp_path) -> original = tmp_path / "screenshot_001.png" image = Image.new("RGB", (4, 4), (255, 0, 0)) image.save(original) - screenshot = Screenshot( - id=1, action_id=1, timestamp=1.0, image=image, path=str(original) - ) + screenshot = Screenshot(id=1, action_id=1, timestamp=1.0, image=image, path=str(original)) scrubbed = screenshot.scrub(_StubScrubber()) @@ -141,3 +166,58 @@ def test_empty_screenshot_is_still_a_no_op(self) -> None: assert scrubbed.image is None assert scrubbed.path is None + + def test_text_only_scrub_omits_raw_screenshot_content(self, tmp_path) -> None: + original = tmp_path / "screenshot_001.png" + image = Image.new("RGB", (4, 4), (255, 0, 0)) + image.save(original) + recording = Recording( + task_description="Patient John Smith", + screenshots=[ + Screenshot( + id=1, + action_id=1, + timestamp=1.0, + image=image, + path=str(original), + ) + ], + ) + + scrubbed = recording.scrub(_TextStubScrubber(), scrub_images=False) + + assert scrubbed.screenshots[0].image is None + assert scrubbed.screenshots[0].path is None + assert scrubbed.metadata["_openadapt_privacy"]["omitted_modalities"] == [Modality.PIL_IMAGE] + + +class TestScrubAdmissionAndEvidence: + def test_dependency_failure_happens_before_source_processing(self) -> None: + scrubber = _UnavailableTextScrubber() + recording = Recording( + task_description="Patient John Smith", + actions=[Action(id=1, action_type="type", timestamp=1.0, text="secret")], + ) + + with pytest.raises(ScrubbingProviderUnavailable): + recording.scrub(scrubber, scrub_images=False) + + assert scrubber.calls == 0 + + def test_completed_scrub_attaches_policy_and_version_provenance(self) -> None: + recording = Recording(task_description="Patient John Smith") + + scrubbed = recording.scrub(_TextStubScrubber(), scrub_images=False) + + evidence = scrubbed.metadata["_openadapt_privacy"] + assert evidence == { + "schema_version": 1, + "provider": "text-stub", + "provider_class": ("tests.test_no_silent_scrub_bypass._TextStubScrubber"), + "package_version": openadapt_privacy.__version__, + "policy_sha256": config.policy_digest(), + "modalities": [Modality.TEXT], + "status": "completed", + "omitted_modalities": [Modality.PIL_IMAGE], + } + assert "John Smith" not in repr(evidence) diff --git a/tests/test_providers.py b/tests/test_providers.py index a7953eb..d804eca 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -30,7 +30,7 @@ def test_get_available_providers(self) -> None: assert isinstance(providers, list) assert ScrubProvider.PRESIDIO in providers - assert ScrubProvider.PRIVATE_AI in providers + assert ScrubProvider.PRIVATE_AI not in providers def test_get_scrubber_invalid_provider(self) -> None: """Test get_scrubber raises error for invalid provider.""" From c369952152af0a446236a13aa493a8dac746bc81 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 17:03:20 -0400 Subject: [PATCH 2/6] fix: bind scrub evidence to one policy snapshot --- openadapt_privacy/__init__.py | 3 +++ openadapt_privacy/base.py | 15 ++++++++++++--- openadapt_privacy/loaders.py | 18 +++++++++++++----- tests/test_no_silent_scrub_bypass.py | 17 +++++++++++++++++ 4 files changed, 45 insertions(+), 8 deletions(-) diff --git a/openadapt_privacy/__init__.py b/openadapt_privacy/__init__.py index 4a3009d..3ebed58 100644 --- a/openadapt_privacy/__init__.py +++ b/openadapt_privacy/__init__.py @@ -6,6 +6,7 @@ from openadapt_privacy.base import ( Modality, + ScrubbingPolicyChanged, ScrubbingProvider, ScrubbingProviderFactory, ScrubbingProviderUnavailable, @@ -79,11 +80,13 @@ def __dir__() -> list[str]: """Return the package attribute names, including lazy re-exports.""" return sorted(set(globals()) | set(_LAZY_EXPORTS)) + __all__ = [ # Base classes "Modality", "ScrubbingProvider", "ScrubbingProviderFactory", + "ScrubbingPolicyChanged", "ScrubbingProviderUnavailable", "TextScrubbingMixin", # Config diff --git a/openadapt_privacy/base.py b/openadapt_privacy/base.py index 0463f7a..987a57c 100644 --- a/openadapt_privacy/base.py +++ b/openadapt_privacy/base.py @@ -8,7 +8,7 @@ import importlib from importlib import metadata -from typing import Any, List +from typing import Any, List, Optional from PIL import Image from pydantic import BaseModel @@ -32,6 +32,10 @@ class ScrubbingProviderUnavailable(RuntimeError): """ +class ScrubbingPolicyChanged(RuntimeError): + """The effective privacy policy changed while a scrub was running.""" + + class Modality: """Supported modality types for scrubbing.""" @@ -61,7 +65,12 @@ def validate_ready(self, modalities: list[str]) -> None: "no scrub was attempted." ) - def evidence(self, modalities: list[str]) -> dict[str, Any]: + def evidence( + self, + modalities: list[str], + *, + policy_sha256: Optional[str] = None, + ) -> dict[str, Any]: """Return privacy-safe provenance for a completed scrub operation.""" try: package_version = metadata.version("openadapt-privacy") @@ -72,7 +81,7 @@ def evidence(self, modalities: list[str]) -> dict[str, Any]: "provider": self.name, "provider_class": f"{type(self).__module__}.{type(self).__qualname__}", "package_version": package_version, - "policy_sha256": config.policy_digest(), + "policy_sha256": policy_sha256 or config.policy_digest(), "modalities": sorted(set(modalities)), "status": "completed", } diff --git a/openadapt_privacy/loaders.py b/openadapt_privacy/loaders.py index 8077d35..a4e203e 100644 --- a/openadapt_privacy/loaders.py +++ b/openadapt_privacy/loaders.py @@ -35,7 +35,8 @@ def save(self, recording: Recording, path: str) -> None: from PIL import Image -from openadapt_privacy.base import ScrubbingProvider +from openadapt_privacy.base import ScrubbingPolicyChanged, ScrubbingProvider +from openadapt_privacy.config import config class UnscrubbedScreenshot(RuntimeError): @@ -195,11 +196,15 @@ def scrub( Returns: New Recording instance with all content scrubbed. """ + policy_sha256 = config.policy_digest() required_modalities = ["TEXT"] if scrub_images and self.screenshots: required_modalities.append("PIL_IMAGE") scrubber.validate_ready(required_modalities) + scrubbed_task_description = ( + scrubber.scrub_text(self.task_description) if self.task_description else None + ) scrubbed_actions = [action.scrub(scrubber) for action in self.actions] scrubbed_screenshots = ( [screenshot.scrub(scrubber) for screenshot in self.screenshots] @@ -215,16 +220,19 @@ def scrub( ) scrubbed_metadata = scrubber.scrub_dict(self.metadata) if self.metadata else {} + if config.policy_digest() != policy_sha256: + raise ScrubbingPolicyChanged( + "The effective privacy policy changed while scrubbing. The mixed-policy " + "result was discarded; retry from the original inside one policy snapshot." + ) scrubbed_metadata["_openadapt_privacy"] = { - **scrubber.evidence(required_modalities), + **scrubber.evidence(required_modalities, policy_sha256=policy_sha256), "omitted_modalities": [] if scrub_images else ["PIL_IMAGE"], } return Recording( id=self.id, - task_description=( - scrubber.scrub_text(self.task_description) if self.task_description else None - ), + task_description=scrubbed_task_description, timestamp=self.timestamp, actions=scrubbed_actions, screenshots=scrubbed_screenshots, diff --git a/tests/test_no_silent_scrub_bypass.py b/tests/test_no_silent_scrub_bypass.py index e45fd3a..89eaa7e 100644 --- a/tests/test_no_silent_scrub_bypass.py +++ b/tests/test_no_silent_scrub_bypass.py @@ -18,6 +18,7 @@ import openadapt_privacy from openadapt_privacy.base import ( Modality, + ScrubbingPolicyChanged, ScrubbingProvider, ScrubbingProviderUnavailable, ) @@ -74,6 +75,12 @@ def validate_ready(self, modalities: list[str]) -> None: raise ScrubbingProviderUnavailable("dependency missing; no scrub was attempted") +class _PolicyMutatingScrubber(_TextStubScrubber): + def scrub_text(self, text: str, is_separated: bool = False) -> str: + config.SCRUB_CHAR = "!" if config.SCRUB_CHAR != "!" else "#" + return super().scrub_text(text, is_separated=is_separated) + + class TestPackageRootExports: """The documented consumer import must work on a complete install.""" @@ -204,6 +211,16 @@ def test_dependency_failure_happens_before_source_processing(self) -> None: assert scrubber.calls == 0 + def test_policy_change_during_scrub_discards_the_result(self) -> None: + original = config.SCRUB_CHAR + try: + with pytest.raises(ScrubbingPolicyChanged): + Recording(task_description="Patient John Smith").scrub( + _PolicyMutatingScrubber(), scrub_images=False + ) + finally: + config.SCRUB_CHAR = original + def test_completed_scrub_attaches_policy_and_version_provenance(self) -> None: recording = Recording(task_description="Patient John Smith") From 32121ecd332d0461d0d284adb78e3e4cdeaf79c6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 17:17:33 -0400 Subject: [PATCH 3/6] fix: bind Presidio caches to privacy policy --- openadapt_privacy/providers/presidio.py | 15 +++++ tests/test_no_silent_scrub_bypass.py | 76 +++++++++++++++++++++++++ 2 files changed, 91 insertions(+) diff --git a/openadapt_privacy/providers/presidio.py b/openadapt_privacy/providers/presidio.py index fc3bd04..09aaebe 100644 --- a/openadapt_privacy/providers/presidio.py +++ b/openadapt_privacy/providers/presidio.py @@ -46,6 +46,20 @@ class PrivacyModelUnavailable(RuntimeError): _anonymizer_engine = None _image_redactor_engine = None _scrubbing_entities = None +_analyzer_policy_sha256 = None + + +def _invalidate_policy_bound_caches(current_policy_sha256: str) -> None: + """Discard analyzer-derived state when the effective policy changes.""" + global _analyzer_engine, _analyzer_policy_sha256, _image_redactor_engine + global _scrubbing_entities + + if _analyzer_policy_sha256 == current_policy_sha256: + return + _analyzer_engine = None + _image_redactor_engine = None + _scrubbing_entities = None + _analyzer_policy_sha256 = current_policy_sha256 def _ensure_spacy_model() -> None: @@ -132,6 +146,7 @@ def _get_analyzer_engine(): # Revalidate on every access so a cached analyzer cannot mask a later, # operator-controlled configuration change. _ensure_spacy_model() + _invalidate_policy_bound_caches(config.policy_digest()) if _analyzer_engine is None: with warnings.catch_warnings(): warnings.simplefilter("ignore") diff --git a/tests/test_no_silent_scrub_bypass.py b/tests/test_no_silent_scrub_bypass.py index 89eaa7e..8b24320 100644 --- a/tests/test_no_silent_scrub_bypass.py +++ b/tests/test_no_silent_scrub_bypass.py @@ -10,6 +10,7 @@ import subprocess import sys +import types from importlib import metadata import pytest @@ -24,6 +25,7 @@ ) from openadapt_privacy.config import config from openadapt_privacy.loaders import Action, Recording, Screenshot, UnscrubbedScreenshot +from openadapt_privacy.providers import presidio def _run_in_fresh_interpreter(source: str) -> subprocess.CompletedProcess[str]: @@ -211,6 +213,80 @@ def test_dependency_failure_happens_before_source_processing(self) -> None: assert scrubber.calls == 0 + def test_policy_change_invalidates_analyzer_derived_caches(self, monkeypatch) -> None: + old_digest = config.policy_digest() + monkeypatch.setattr(presidio, "_analyzer_policy_sha256", old_digest) + monkeypatch.setattr(presidio, "_analyzer_engine", object()) + monkeypatch.setattr(presidio, "_image_redactor_engine", object()) + monkeypatch.setattr(presidio, "_scrubbing_entities", ["EMAIL_ADDRESS"]) + monkeypatch.setattr( + config, + "SCRUB_PRESIDIO_IGNORE_ENTITIES", + [*config.SCRUB_PRESIDIO_IGNORE_ENTITIES, "EMAIL_ADDRESS"], + ) + new_digest = config.policy_digest() + assert new_digest != old_digest + + presidio._invalidate_policy_bound_caches(new_digest) + + assert presidio._analyzer_policy_sha256 == new_digest + assert presidio._analyzer_engine is None + assert presidio._image_redactor_engine is None + assert presidio._scrubbing_entities is None + + def test_tightened_policy_rebuilds_entities_before_second_scrub(self, monkeypatch) -> None: + class FakeRegistry: + def add_recognizer(self, _recognizer) -> None: + return None + + class FakeAnalyzerEngine: + def __init__(self, **_kwargs) -> None: + self.registry = FakeRegistry() + + def get_supported_entities(self) -> list[str]: + return ["EMAIL_ADDRESS", "PHONE_NUMBER"] + + class FakeNlpEngineProvider: + def __init__(self, **_kwargs) -> None: + pass + + def create_engine(self) -> object: + return object() + + monkeypatch.setitem( + sys.modules, + "presidio_analyzer", + types.SimpleNamespace(AnalyzerEngine=FakeAnalyzerEngine), + ) + monkeypatch.setitem( + sys.modules, + "presidio_analyzer.nlp_engine", + types.SimpleNamespace(NlpEngineProvider=FakeNlpEngineProvider), + ) + monkeypatch.setattr(presidio, "_ensure_spacy_model", lambda: None) + monkeypatch.setattr(presidio, "_register_phi_recognizers", lambda _engine: None) + monkeypatch.setattr(presidio, "_analyzer_engine", None) + monkeypatch.setattr(presidio, "_image_redactor_engine", None) + monkeypatch.setattr(presidio, "_scrubbing_entities", None) + monkeypatch.setattr(presidio, "_analyzer_policy_sha256", None) + + monkeypatch.setattr( + config, + "SCRUB_PRESIDIO_IGNORE_ENTITIES", + ["EMAIL_ADDRESS"], + ) + first_engine = presidio._get_analyzer_engine() + assert presidio._get_scrubbing_entities() == ["PHONE_NUMBER"] + + monkeypatch.setattr(config, "SCRUB_PRESIDIO_IGNORE_ENTITIES", []) + second_engine = presidio._get_analyzer_engine() + + assert second_engine is not first_engine + assert presidio._get_scrubbing_entities() == [ + "EMAIL_ADDRESS", + "PHONE_NUMBER", + ] + def test_policy_change_during_scrub_discards_the_result(self) -> None: original = config.SCRUB_CHAR try: From 960f1f0cd4ab1400cb2ee3c2d296815340b1777d Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 17:25:51 -0400 Subject: [PATCH 4/6] fix: bind scrubbing to an operation policy snapshot --- openadapt_privacy/base.py | 8 +- openadapt_privacy/config.py | 26 +++++- openadapt_privacy/loaders.py | 64 ++++++------- openadapt_privacy/providers/presidio.py | 117 ++++++++++++------------ tests/test_no_silent_scrub_bypass.py | 18 ++-- 5 files changed, 130 insertions(+), 103 deletions(-) diff --git a/openadapt_privacy/base.py b/openadapt_privacy/base.py index 987a57c..eb7df63 100644 --- a/openadapt_privacy/base.py +++ b/openadapt_privacy/base.py @@ -13,7 +13,7 @@ from PIL import Image from pydantic import BaseModel -from openadapt_privacy.config import config +from openadapt_privacy.config import effective_config # Provider modules that must be imported before the subclass registry is read. # ``ScrubbingProvider.__subclasses__()`` only sees classes whose module has been @@ -81,7 +81,7 @@ def evidence( "provider": self.name, "provider_class": f"{type(self).__module__}.{type(self).__qualname__}", "package_version": package_version, - "policy_sha256": policy_sha256 or config.policy_digest(), + "policy_sha256": policy_sha256 or effective_config().policy_digest(), "modalities": sorted(set(modalities)), "status": "completed", } @@ -187,7 +187,7 @@ def scrub_text_all(self, text: str) -> str: """ if text is None: return None - return config.SCRUB_CHAR * len(text) + return effective_config().SCRUB_CHAR * len(text) def scrub_dict( self, @@ -213,7 +213,7 @@ def scrub_dict( Scrubbed dictionary with PII/PHI removed. """ if list_keys is None: - list_keys = config.SCRUB_KEYS_HTML + list_keys = effective_config().SCRUB_KEYS_HTML scrubbed_dict: dict[str, Any] = {} for key, value in input_dict.items(): diff --git a/openadapt_privacy/config.py b/openadapt_privacy/config.py index 447abff..88148e1 100644 --- a/openadapt_privacy/config.py +++ b/openadapt_privacy/config.py @@ -9,8 +9,11 @@ import hashlib import json +from contextlib import contextmanager +from contextvars import ContextVar +from copy import deepcopy from dataclasses import asdict, dataclass, field -from typing import Sequence +from typing import Iterator, Sequence @dataclass @@ -91,3 +94,24 @@ def policy_digest(self) -> str: # Global default configuration instance config = PrivacyConfig() + +_operation_config: ContextVar[PrivacyConfig | None] = ContextVar( + "openadapt_privacy_operation_config", + default=None, +) + + +def effective_config() -> PrivacyConfig: + """Return the immutable-per-operation policy, or the process default.""" + return _operation_config.get() or config + + +@contextmanager +def privacy_operation() -> Iterator[PrivacyConfig]: + """Bind one deep-copied policy snapshot for a complete scrub operation.""" + snapshot = deepcopy(config) + token = _operation_config.set(snapshot) + try: + yield snapshot + finally: + _operation_config.reset(token) diff --git a/openadapt_privacy/loaders.py b/openadapt_privacy/loaders.py index a4e203e..f430953 100644 --- a/openadapt_privacy/loaders.py +++ b/openadapt_privacy/loaders.py @@ -35,8 +35,8 @@ def save(self, recording: Recording, path: str) -> None: from PIL import Image -from openadapt_privacy.base import ScrubbingPolicyChanged, ScrubbingProvider -from openadapt_privacy.config import config +from openadapt_privacy.base import ScrubbingProvider +from openadapt_privacy.config import privacy_operation class UnscrubbedScreenshot(RuntimeError): @@ -196,39 +196,35 @@ def scrub( Returns: New Recording instance with all content scrubbed. """ - policy_sha256 = config.policy_digest() - required_modalities = ["TEXT"] - if scrub_images and self.screenshots: - required_modalities.append("PIL_IMAGE") - scrubber.validate_ready(required_modalities) - - scrubbed_task_description = ( - scrubber.scrub_text(self.task_description) if self.task_description else None - ) - scrubbed_actions = [action.scrub(scrubber) for action in self.actions] - scrubbed_screenshots = ( - [screenshot.scrub(scrubber) for screenshot in self.screenshots] - if scrub_images - else [ - Screenshot( - id=screenshot.id, - action_id=screenshot.action_id, - timestamp=screenshot.timestamp, - ) - for screenshot in self.screenshots - ] - ) - - scrubbed_metadata = scrubber.scrub_dict(self.metadata) if self.metadata else {} - if config.policy_digest() != policy_sha256: - raise ScrubbingPolicyChanged( - "The effective privacy policy changed while scrubbing. The mixed-policy " - "result was discarded; retry from the original inside one policy snapshot." + with privacy_operation() as policy: + policy_sha256 = policy.policy_digest() + required_modalities = ["TEXT"] + if scrub_images and self.screenshots: + required_modalities.append("PIL_IMAGE") + scrubber.validate_ready(required_modalities) + + scrubbed_task_description = ( + scrubber.scrub_text(self.task_description) if self.task_description else None ) - scrubbed_metadata["_openadapt_privacy"] = { - **scrubber.evidence(required_modalities, policy_sha256=policy_sha256), - "omitted_modalities": [] if scrub_images else ["PIL_IMAGE"], - } + scrubbed_actions = [action.scrub(scrubber) for action in self.actions] + scrubbed_screenshots = ( + [screenshot.scrub(scrubber) for screenshot in self.screenshots] + if scrub_images + else [ + Screenshot( + id=screenshot.id, + action_id=screenshot.action_id, + timestamp=screenshot.timestamp, + ) + for screenshot in self.screenshots + ] + ) + + scrubbed_metadata = scrubber.scrub_dict(self.metadata) if self.metadata else {} + scrubbed_metadata["_openadapt_privacy"] = { + **scrubber.evidence(required_modalities, policy_sha256=policy_sha256), + "omitted_modalities": [] if scrub_images else ["PIL_IMAGE"], + } return Recording( id=self.id, diff --git a/openadapt_privacy/providers/presidio.py b/openadapt_privacy/providers/presidio.py index 09aaebe..5f02ad1 100644 --- a/openadapt_privacy/providers/presidio.py +++ b/openadapt_privacy/providers/presidio.py @@ -8,13 +8,14 @@ from __future__ import annotations import logging +import threading import warnings from typing import List from PIL import Image from openadapt_privacy.base import Modality, ScrubbingProvider, TextScrubbingMixin -from openadapt_privacy.config import config +from openadapt_privacy.config import effective_config logger = logging.getLogger(__name__) @@ -47,6 +48,7 @@ class PrivacyModelUnavailable(RuntimeError): _image_redactor_engine = None _scrubbing_entities = None _analyzer_policy_sha256 = None +_cache_lock = threading.RLock() def _invalidate_policy_bound_caches(current_policy_sha256: str) -> None: @@ -66,31 +68,32 @@ def _ensure_spacy_model() -> None: """Validate the configured local model without downloading any code.""" import spacy - allowed_models = SUPPORTED_SPACY_MODELS.get(config.SCRUB_LANGUAGE) + policy = effective_config() + allowed_models = SUPPORTED_SPACY_MODELS.get(policy.SCRUB_LANGUAGE) if allowed_models is None: raise PrivacyModelUnavailable( - f"Refusing unsupported scrub language {config.SCRUB_LANGUAGE!r}; " + f"Refusing unsupported scrub language {policy.SCRUB_LANGUAGE!r}; " f"allowed languages: {sorted(SUPPORTED_SPACY_MODELS)}" ) expected_config = { "nlp_engine_name": "spacy", - "models": [{"lang_code": config.SCRUB_LANGUAGE, "model_name": config.SPACY_MODEL_NAME}], + "models": [{"lang_code": policy.SCRUB_LANGUAGE, "model_name": policy.SPACY_MODEL_NAME}], } - if config.SPACY_MODEL_NAME not in allowed_models: + if policy.SPACY_MODEL_NAME not in allowed_models: raise PrivacyModelUnavailable( - f"Refusing unapproved spaCy model {config.SPACY_MODEL_NAME!r}; " - f"allowed models for {config.SCRUB_LANGUAGE!r}: {sorted(allowed_models)}" + f"Refusing unapproved spaCy model {policy.SPACY_MODEL_NAME!r}; " + f"allowed models for {policy.SCRUB_LANGUAGE!r}: {sorted(allowed_models)}" ) - if config.SCRUB_CONFIG_TRF != expected_config: + if policy.SCRUB_CONFIG_TRF != expected_config: raise PrivacyModelUnavailable( "Refusing inconsistent Presidio NLP configuration; model selection " "must match the allowlisted local SPACY_MODEL_NAME" ) - if not spacy.util.is_package(config.SPACY_MODEL_NAME): + if not spacy.util.is_package(policy.SPACY_MODEL_NAME): raise PrivacyModelUnavailable( - f"Required spaCy model {config.SPACY_MODEL_NAME!r} is not installed. " + f"Required spaCy model {policy.SPACY_MODEL_NAME!r} is not installed. " f"Install it explicitly with: python -m spacy download " - f"{config.SPACY_MODEL_NAME}. No scrub was attempted." + f"{policy.SPACY_MODEL_NAME}. No scrub was attempted." ) @@ -143,32 +146,34 @@ def _get_analyzer_engine(): """Get or create the Presidio analyzer engine (lazy initialization).""" global _analyzer_engine, _scrubbing_entities - # Revalidate on every access so a cached analyzer cannot mask a later, - # operator-controlled configuration change. - _ensure_spacy_model() - _invalidate_policy_bound_caches(config.policy_digest()) - if _analyzer_engine is None: - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - from presidio_analyzer import AnalyzerEngine - from presidio_analyzer.nlp_engine import NlpEngineProvider - - nlp_provider = NlpEngineProvider(nlp_configuration=config.SCRUB_CONFIG_TRF) - nlp_engine = nlp_provider.create_engine() - _analyzer_engine = AnalyzerEngine( - nlp_engine=nlp_engine, - supported_languages=[config.SCRUB_LANGUAGE], - ) - _register_phi_recognizers(_analyzer_engine) - - # Cache the scrubbing entities - _scrubbing_entities = [ - entity - for entity in _analyzer_engine.get_supported_entities() - if entity not in config.SCRUB_PRESIDIO_IGNORE_ENTITIES - ] - - return _analyzer_engine + with _cache_lock: + # Revalidate on every access so a cached analyzer cannot mask a later, + # operator-controlled configuration change. + _ensure_spacy_model() + policy = effective_config() + _invalidate_policy_bound_caches(policy.policy_digest()) + if _analyzer_engine is None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from presidio_analyzer import AnalyzerEngine + from presidio_analyzer.nlp_engine import NlpEngineProvider + + nlp_provider = NlpEngineProvider(nlp_configuration=policy.SCRUB_CONFIG_TRF) + nlp_engine = nlp_provider.create_engine() + _analyzer_engine = AnalyzerEngine( + nlp_engine=nlp_engine, + supported_languages=[policy.SCRUB_LANGUAGE], + ) + _register_phi_recognizers(_analyzer_engine) + + # Cache the scrubbing entities + _scrubbing_entities = [ + entity + for entity in _analyzer_engine.get_supported_entities() + if entity not in policy.SCRUB_PRESIDIO_IGNORE_ENTITIES + ] + + return _analyzer_engine def _get_anonymizer_engine(): @@ -189,26 +194,25 @@ def _get_image_redactor_engine(): """Get or create the Presidio image redactor engine (lazy initialization).""" global _image_redactor_engine - if _image_redactor_engine is None: + with _cache_lock: analyzer = _get_analyzer_engine() + if _image_redactor_engine is None: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from presidio_image_redactor import ImageAnalyzerEngine, ImageRedactorEngine - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - from presidio_image_redactor import ImageAnalyzerEngine, ImageRedactorEngine + _image_redactor_engine = ImageRedactorEngine(ImageAnalyzerEngine(analyzer)) - _image_redactor_engine = ImageRedactorEngine(ImageAnalyzerEngine(analyzer)) - - return _image_redactor_engine + return _image_redactor_engine def _get_scrubbing_entities() -> List[str]: """Get the list of entity types to scrub.""" global _scrubbing_entities - if _scrubbing_entities is None: - _get_analyzer_engine() # This will populate _scrubbing_entities - - return _scrubbing_entities + with _cache_lock: + _get_analyzer_engine() + return list(_scrubbing_entities) class PresidioScrubbingProvider(ScrubbingProvider, TextScrubbingMixin): @@ -251,6 +255,7 @@ def scrub_text(self, text: str, is_separated: bool = False) -> str: if text is None: return None + policy = effective_config() analyzer = _get_analyzer_engine() anonymizer = _get_anonymizer_engine() entities = _get_scrubbing_entities() @@ -258,16 +263,16 @@ def scrub_text(self, text: str, is_separated: bool = False) -> str: # Handle separated text (e.g., key sequences) original_text = text if is_separated and not ( - text.startswith(config.ACTION_TEXT_NAME_PREFIX) - or text.endswith(config.ACTION_TEXT_NAME_SUFFIX) + text.startswith(policy.ACTION_TEXT_NAME_PREFIX) + or text.endswith(policy.ACTION_TEXT_NAME_SUFFIX) ): - text = "".join(text.split(config.ACTION_TEXT_SEP)) + text = "".join(text.split(policy.ACTION_TEXT_SEP)) # Analyze and anonymize analyzer_results = analyzer.analyze( text=text, entities=entities, - language=config.SCRUB_LANGUAGE, + language=policy.SCRUB_LANGUAGE, ) analyzer_results = _filter_automation_false_positives(text, analyzer_results) @@ -284,10 +289,10 @@ def scrub_text(self, text: str, is_separated: bool = False) -> str: # Restore separator format if needed if is_separated and not ( - original_text.startswith(config.ACTION_TEXT_NAME_PREFIX) - or original_text.endswith(config.ACTION_TEXT_NAME_SUFFIX) + original_text.startswith(policy.ACTION_TEXT_NAME_PREFIX) + or original_text.endswith(policy.ACTION_TEXT_NAME_SUFFIX) ): - result_text = config.ACTION_TEXT_SEP.join(result_text) + result_text = policy.ACTION_TEXT_SEP.join(result_text) return result_text @@ -307,7 +312,7 @@ def scrub_image( Scrubbed image with PII/PHI redacted. """ if fill_color is None: - fill_color = config.SCRUB_FILL_COLOR + fill_color = effective_config().SCRUB_FILL_COLOR redactor = _get_image_redactor_engine() entities = _get_scrubbing_entities() diff --git a/tests/test_no_silent_scrub_bypass.py b/tests/test_no_silent_scrub_bypass.py index 8b24320..fc4cbcb 100644 --- a/tests/test_no_silent_scrub_bypass.py +++ b/tests/test_no_silent_scrub_bypass.py @@ -19,11 +19,10 @@ import openadapt_privacy from openadapt_privacy.base import ( Modality, - ScrubbingPolicyChanged, ScrubbingProvider, ScrubbingProviderUnavailable, ) -from openadapt_privacy.config import config +from openadapt_privacy.config import config, effective_config from openadapt_privacy.loaders import Action, Recording, Screenshot, UnscrubbedScreenshot from openadapt_privacy.providers import presidio @@ -80,7 +79,8 @@ def validate_ready(self, modalities: list[str]) -> None: class _PolicyMutatingScrubber(_TextStubScrubber): def scrub_text(self, text: str, is_separated: bool = False) -> str: config.SCRUB_CHAR = "!" if config.SCRUB_CHAR != "!" else "#" - return super().scrub_text(text, is_separated=is_separated) + self.calls += 1 + return effective_config().SCRUB_CHAR * len(text) class TestPackageRootExports: @@ -287,13 +287,15 @@ def create_engine(self) -> object: "PHONE_NUMBER", ] - def test_policy_change_during_scrub_discards_the_result(self) -> None: + def test_policy_change_during_scrub_cannot_change_operation_snapshot(self) -> None: original = config.SCRUB_CHAR + original_digest = config.policy_digest() try: - with pytest.raises(ScrubbingPolicyChanged): - Recording(task_description="Patient John Smith").scrub( - _PolicyMutatingScrubber(), scrub_images=False - ) + scrubbed = Recording(task_description="Patient John Smith").scrub( + _PolicyMutatingScrubber(), scrub_images=False + ) + assert scrubbed.task_description == original * len("Patient John Smith") + assert scrubbed.metadata["_openadapt_privacy"]["policy_sha256"] == original_digest finally: config.SCRUB_CHAR = original From b4ba690a12d8a1a19ce414e678f0f67e6507b504 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 17:53:30 -0400 Subject: [PATCH 5/6] fix: freeze policy across public scrub providers --- openadapt_privacy/__init__.py | 3 +- openadapt_privacy/base.py | 4 -- openadapt_privacy/config.py | 83 ++++++++++++++++++++++++++-- tests/test_no_silent_scrub_bypass.py | 31 ++++++++--- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/openadapt_privacy/__init__.py b/openadapt_privacy/__init__.py index 3ebed58..2d8abb2 100644 --- a/openadapt_privacy/__init__.py +++ b/openadapt_privacy/__init__.py @@ -6,13 +6,12 @@ from openadapt_privacy.base import ( Modality, - ScrubbingPolicyChanged, ScrubbingProvider, ScrubbingProviderFactory, ScrubbingProviderUnavailable, TextScrubbingMixin, ) -from openadapt_privacy.config import PrivacyConfig, config +from openadapt_privacy.config import PrivacyConfig, ScrubbingPolicyChanged, config from openadapt_privacy.loaders import ( Action, DictRecordingLoader, diff --git a/openadapt_privacy/base.py b/openadapt_privacy/base.py index eb7df63..c60d266 100644 --- a/openadapt_privacy/base.py +++ b/openadapt_privacy/base.py @@ -32,10 +32,6 @@ class ScrubbingProviderUnavailable(RuntimeError): """ -class ScrubbingPolicyChanged(RuntimeError): - """The effective privacy policy changed while a scrub was running.""" - - class Modality: """Supported modality types for scrubbing.""" diff --git a/openadapt_privacy/config.py b/openadapt_privacy/config.py index 88148e1..01fb519 100644 --- a/openadapt_privacy/config.py +++ b/openadapt_privacy/config.py @@ -13,9 +13,51 @@ from contextvars import ContextVar from copy import deepcopy from dataclasses import asdict, dataclass, field +from threading import RLock from typing import Iterator, Sequence +class ScrubbingPolicyChanged(RuntimeError): + """A scrubber attempted to mutate policy during a scrub operation.""" + + +class _FrozenList(list): + """List-compatible policy value that refuses in-operation mutation.""" + + def _refuse(self, *_args, **_kwargs) -> None: + raise ScrubbingPolicyChanged("privacy policy is immutable during a scrub operation") + + __setitem__ = __delitem__ = append = clear = extend = insert = pop = remove = reverse = sort = ( + _refuse + ) + __iadd__ = __imul__ = _refuse + + def __deepcopy__(self, _memo): + return self + + +class _FrozenDict(dict): + """Dict-compatible policy value that refuses in-operation mutation.""" + + def _refuse(self, *_args, **_kwargs) -> None: + raise ScrubbingPolicyChanged("privacy policy is immutable during a scrub operation") + + __setitem__ = __delitem__ = clear = pop = popitem = setdefault = update = _refuse + + def __deepcopy__(self, _memo): + return self + + +def _freeze_policy_value(value): + if isinstance(value, dict): + return _FrozenDict({key: _freeze_policy_value(item) for key, item in value.items()}) + if isinstance(value, list): + return _FrozenList(_freeze_policy_value(item) for item in value) + if isinstance(value, tuple): + return tuple(_freeze_policy_value(item) for item in value) + return value + + @dataclass class PrivacyConfig: """Configuration for privacy scrubbing operations. @@ -81,6 +123,11 @@ class PrivacyConfig: # SpaCy model name SPACY_MODEL_NAME: str = "en_core_web_sm" + def __setattr__(self, name: str, value) -> None: + if self.__dict__.get("_policy_locked", False) and not name.startswith("_"): + raise ScrubbingPolicyChanged("privacy policy is immutable during a scrub operation") + object.__setattr__(self, name, value) + def policy_digest(self) -> str: """Return a stable digest of the complete effective scrub policy.""" payload = json.dumps( @@ -95,6 +142,8 @@ def policy_digest(self) -> str: # Global default configuration instance config = PrivacyConfig() +_operation_lock = RLock() + _operation_config: ContextVar[PrivacyConfig | None] = ContextVar( "openadapt_privacy_operation_config", default=None, @@ -109,9 +158,31 @@ def effective_config() -> PrivacyConfig: @contextmanager def privacy_operation() -> Iterator[PrivacyConfig]: """Bind one deep-copied policy snapshot for a complete scrub operation.""" - snapshot = deepcopy(config) - token = _operation_config.set(snapshot) - try: - yield snapshot - finally: - _operation_config.reset(token) + existing = _operation_config.get() + if existing is not None: + yield existing + return + + # Serialize policy admission. During the operation both the explicit + # snapshot and the legacy public ``config`` object are read-compatible but + # mutation-proof, so older third-party providers cannot silently mix two + # policies and still receive completed evidence. + with _operation_lock: + snapshot = deepcopy(config) + global_values = {name: deepcopy(value) for name, value in asdict(config).items()} + for name, value in asdict(snapshot).items(): + object.__setattr__(snapshot, name, _freeze_policy_value(value)) + object.__setattr__(snapshot, "_policy_locked", True) + + for name, value in global_values.items(): + object.__setattr__(config, name, _freeze_policy_value(value)) + object.__setattr__(config, "_policy_locked", True) + + token = _operation_config.set(snapshot) + try: + yield snapshot + finally: + _operation_config.reset(token) + object.__setattr__(config, "_policy_locked", False) + for name, value in global_values.items(): + object.__setattr__(config, name, value) diff --git a/tests/test_no_silent_scrub_bypass.py b/tests/test_no_silent_scrub_bypass.py index fc4cbcb..858bf54 100644 --- a/tests/test_no_silent_scrub_bypass.py +++ b/tests/test_no_silent_scrub_bypass.py @@ -22,7 +22,7 @@ ScrubbingProvider, ScrubbingProviderUnavailable, ) -from openadapt_privacy.config import config, effective_config +from openadapt_privacy.config import ScrubbingPolicyChanged, config from openadapt_privacy.loaders import Action, Recording, Screenshot, UnscrubbedScreenshot from openadapt_privacy.providers import presidio @@ -80,7 +80,13 @@ class _PolicyMutatingScrubber(_TextStubScrubber): def scrub_text(self, text: str, is_separated: bool = False) -> str: config.SCRUB_CHAR = "!" if config.SCRUB_CHAR != "!" else "#" self.calls += 1 - return effective_config().SCRUB_CHAR * len(text) + return config.SCRUB_CHAR * len(text) + + +class _MutablePolicyScrubber(_TextStubScrubber): + def scrub_text(self, text: str, is_separated: bool = False) -> str: + config.SCRUB_KEYS_HTML.append("secret_extension") + return text class TestPackageRootExports: @@ -287,18 +293,27 @@ def create_engine(self) -> object: "PHONE_NUMBER", ] - def test_policy_change_during_scrub_cannot_change_operation_snapshot(self) -> None: + def test_legacy_provider_cannot_mutate_global_policy_during_scrub(self) -> None: original = config.SCRUB_CHAR original_digest = config.policy_digest() try: - scrubbed = Recording(task_description="Patient John Smith").scrub( - _PolicyMutatingScrubber(), scrub_images=False - ) - assert scrubbed.task_description == original * len("Patient John Smith") - assert scrubbed.metadata["_openadapt_privacy"]["policy_sha256"] == original_digest + with pytest.raises(ScrubbingPolicyChanged): + Recording(task_description="Patient John Smith").scrub( + _PolicyMutatingScrubber(), scrub_images=False + ) + assert config.SCRUB_CHAR == original + assert config.policy_digest() == original_digest finally: config.SCRUB_CHAR = original + def test_legacy_provider_cannot_mutate_nested_policy_during_scrub(self) -> None: + original = list(config.SCRUB_KEYS_HTML) + with pytest.raises(ScrubbingPolicyChanged): + Recording(task_description="Patient John Smith").scrub( + _MutablePolicyScrubber(), scrub_images=False + ) + assert config.SCRUB_KEYS_HTML == original + def test_completed_scrub_attaches_policy_and_version_provenance(self) -> None: recording = Recording(task_description="Patient John Smith") From 413fdbedfbea7f25c60031d5fa50b4889f3347c6 Mon Sep 17 00:00:00 2001 From: abrichr Date: Tue, 28 Jul 2026 17:57:09 -0400 Subject: [PATCH 6/6] fix: preserve public privacy config references --- openadapt_privacy/config.py | 4 ++-- tests/test_no_silent_scrub_bypass.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/openadapt_privacy/config.py b/openadapt_privacy/config.py index 01fb519..3f33492 100644 --- a/openadapt_privacy/config.py +++ b/openadapt_privacy/config.py @@ -169,13 +169,13 @@ def privacy_operation() -> Iterator[PrivacyConfig]: # policies and still receive completed evidence. with _operation_lock: snapshot = deepcopy(config) - global_values = {name: deepcopy(value) for name, value in asdict(config).items()} + global_values = {name: getattr(config, name) for name in asdict(config)} for name, value in asdict(snapshot).items(): object.__setattr__(snapshot, name, _freeze_policy_value(value)) object.__setattr__(snapshot, "_policy_locked", True) for name, value in global_values.items(): - object.__setattr__(config, name, _freeze_policy_value(value)) + object.__setattr__(config, name, _freeze_policy_value(deepcopy(value))) object.__setattr__(config, "_policy_locked", True) token = _operation_config.set(snapshot) diff --git a/tests/test_no_silent_scrub_bypass.py b/tests/test_no_silent_scrub_bypass.py index 858bf54..7474a20 100644 --- a/tests/test_no_silent_scrub_bypass.py +++ b/tests/test_no_silent_scrub_bypass.py @@ -314,6 +314,17 @@ def test_legacy_provider_cannot_mutate_nested_policy_during_scrub(self) -> None: ) assert config.SCRUB_KEYS_HTML == original + def test_successful_scrub_restores_public_config_object_identity(self) -> None: + keys = config.SCRUB_KEYS_HTML + nlp_config = config.SCRUB_CONFIG_TRF + + Recording(task_description="Patient John Smith").scrub( + _TextStubScrubber(), scrub_images=False + ) + + assert config.SCRUB_KEYS_HTML is keys + assert config.SCRUB_CONFIG_TRF is nlp_config + def test_completed_scrub_attaches_policy_and_version_provenance(self) -> None: recording = Recording(task_description="Patient John Smith")