deliverd-screen 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,16 @@
1
+ node_modules/
2
+ .pnpm-store/
3
+ dist/
4
+ *.tsbuildinfo
5
+ coverage/
6
+ .DS_Store
7
+
8
+ # packages/sdk-rust
9
+ packages/sdk-rust/target/
10
+
11
+ # packages/sdk-python
12
+ __pycache__/
13
+ *.py[cod]
14
+ .pytest_cache/
15
+ .venv/
16
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Deliverd
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,84 @@
1
+ Metadata-Version: 2.5
2
+ Name: deliverd-screen
3
+ Version: 0.1.0
4
+ Summary: A pre-send check for every prompt. Finds and redacts personal data, secrets and your own confidential terms before text goes to an AI model. Runs locally, no account, no network calls.
5
+ Project-URL: Homepage, https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=metadata
6
+ Project-URL: Source, https://github.com/deliverd-dev/deliverd-sdk/tree/main/packages/screen-python
7
+ Project-URL: Changelog, https://github.com/deliverd-dev/deliverd-sdk/blob/main/packages/screen-python/CHANGELOG.md
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ai,deliverd,dlp,gdpr,llm,pii,privacy,prompt,redaction
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
19
+ Classifier: Topic :: Security
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+
24
+ # Deliverd Screen for Python
25
+
26
+ **A pre-send check for every prompt.** Find and redact personal data, secrets
27
+ and your own confidential terms before text goes to an AI model.
28
+
29
+ It runs locally, with no account, no key and no network call. It needs no dependencies
30
+ and works on Python 3.9 and up. MIT.
31
+
32
+ ```bash
33
+ pip install deliverd-screen
34
+ ```
35
+
36
+ ```python
37
+ from deliverd_screen import screen
38
+
39
+ checked = screen("Reply to mary.byrne@example.ie about IBAN IE29AIBK93115212345678")
40
+ checked.verdict # "redact"
41
+ checked.text # "Reply to [EMAIL_1] about IBAN [IBAN_1]"
42
+
43
+ answer = model.generate(checked.text)
44
+ checked.restore(answer) # the real values, back in the answer
45
+ ```
46
+
47
+ `allow` sends the original; `redact` sends placeholders in place of what the
48
+ model may not receive; `ask` means a person has to decide; `deny` sends nothing.
49
+ By default a model may receive internal data and no personal data. Pass
50
+ `personal_data=True, classification="confidential"` for one approved for more.
51
+
52
+ ## Names: plug in a model
53
+
54
+ ```python
55
+ from transformers import pipeline
56
+ from deliverd_screen.adapters import token_classifier, presidio
57
+
58
+ ner = pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
59
+ checked = screen(prompt, detectors=[token_classifier(ner)])
60
+
61
+ # or Presidio, in process:
62
+ from presidio_analyzer import AnalyzerEngine
63
+ checked = screen(prompt, detectors=[presidio(AnalyzerEngine())])
64
+ ```
65
+
66
+ Any model with the same output works, including Perplexity's PII-Tracer and
67
+ GLiNER. Check its licence before you ship it.
68
+
69
+ ## CLI
70
+
71
+ ```bash
72
+ cat prompt.txt | deliverd-screen --redact
73
+ deliverd-screen --fail --json fixtures/*.json
74
+ ```
75
+
76
+ ## A net, not a certificate
77
+
78
+ Structured identifiers are found well. Names are found only as well as the
79
+ model you plug in, and redacted text about a person is still personal data
80
+ under GDPR.
81
+
82
+ When a person has to decide, [Deliverd](https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=readme)'s
83
+ Data Gate asks them. `deliverd.screen()` in the `deliverd` SDK runs these
84
+ same detectors and sends only counts. It takes the same `detectors`.
@@ -0,0 +1,61 @@
1
+ # Deliverd Screen for Python
2
+
3
+ **A pre-send check for every prompt.** Find and redact personal data, secrets
4
+ and your own confidential terms before text goes to an AI model.
5
+
6
+ It runs locally, with no account, no key and no network call. It needs no dependencies
7
+ and works on Python 3.9 and up. MIT.
8
+
9
+ ```bash
10
+ pip install deliverd-screen
11
+ ```
12
+
13
+ ```python
14
+ from deliverd_screen import screen
15
+
16
+ checked = screen("Reply to mary.byrne@example.ie about IBAN IE29AIBK93115212345678")
17
+ checked.verdict # "redact"
18
+ checked.text # "Reply to [EMAIL_1] about IBAN [IBAN_1]"
19
+
20
+ answer = model.generate(checked.text)
21
+ checked.restore(answer) # the real values, back in the answer
22
+ ```
23
+
24
+ `allow` sends the original; `redact` sends placeholders in place of what the
25
+ model may not receive; `ask` means a person has to decide; `deny` sends nothing.
26
+ By default a model may receive internal data and no personal data. Pass
27
+ `personal_data=True, classification="confidential"` for one approved for more.
28
+
29
+ ## Names: plug in a model
30
+
31
+ ```python
32
+ from transformers import pipeline
33
+ from deliverd_screen.adapters import token_classifier, presidio
34
+
35
+ ner = pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
36
+ checked = screen(prompt, detectors=[token_classifier(ner)])
37
+
38
+ # or Presidio, in process:
39
+ from presidio_analyzer import AnalyzerEngine
40
+ checked = screen(prompt, detectors=[presidio(AnalyzerEngine())])
41
+ ```
42
+
43
+ Any model with the same output works, including Perplexity's PII-Tracer and
44
+ GLiNER. Check its licence before you ship it.
45
+
46
+ ## CLI
47
+
48
+ ```bash
49
+ cat prompt.txt | deliverd-screen --redact
50
+ deliverd-screen --fail --json fixtures/*.json
51
+ ```
52
+
53
+ ## A net, not a certificate
54
+
55
+ Structured identifiers are found well. Names are found only as well as the
56
+ model you plug in, and redacted text about a person is still personal data
57
+ under GDPR.
58
+
59
+ When a person has to decide, [Deliverd](https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=readme)'s
60
+ Data Gate asks them. `deliverd.screen()` in the `deliverd` SDK runs these
61
+ same detectors and sends only counts. It takes the same `detectors`.
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "deliverd-screen"
7
+ version = "0.1.0"
8
+ description = "A pre-send check for every prompt. Finds and redacts personal data, secrets and your own confidential terms before text goes to an AI model. Runs locally, no account, no network calls."
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.9"
13
+ keywords = ["pii", "redaction", "privacy", "dlp", "llm", "prompt", "ai", "gdpr", "deliverd"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "Operating System :: OS Independent",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Programming Language :: Python :: 3.9",
21
+ "Programming Language :: Python :: 3.14",
22
+ "Topic :: Security",
23
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
24
+ "Typing :: Typed",
25
+ ]
26
+ # None. The detectors are the standard library's re; a model for names is
27
+ # yours to install and pass in (deliverd_screen.adapters).
28
+ dependencies = []
29
+
30
+ [project.scripts]
31
+ deliverd-screen = "deliverd_screen.cli:main"
32
+
33
+ [project.urls]
34
+ Homepage = "https://deliverd.dev/docs/developers/screen?utm_source=pypi&utm_medium=package&utm_campaign=screen&utm_content=metadata"
35
+ Source = "https://github.com/deliverd-dev/deliverd-sdk/tree/main/packages/screen-python"
36
+ Changelog = "https://github.com/deliverd-dev/deliverd-sdk/blob/main/packages/screen-python/CHANGELOG.md"
37
+
38
+ [tool.hatch.build.targets.wheel]
39
+ packages = ["src/deliverd_screen"]
40
+ artifacts = ["src/deliverd_screen/py.typed"]
41
+
42
+ [tool.hatch.build.targets.sdist]
43
+ include = ["src/deliverd_screen", "README.md", "LICENSE"]
@@ -0,0 +1,168 @@
1
+ """Deliverd Screen: a pre-send check for every prompt.
2
+
3
+ from deliverd_screen import screen
4
+
5
+ checked = screen(prompt)
6
+ if not checked.allowed:
7
+ raise RuntimeError(" ".join(checked.reasons))
8
+ answer = model.generate(checked.text)
9
+ return checked.restore(answer)
10
+
11
+ Everything runs here. Nothing is sent anywhere: no account, no key, no network
12
+ call. The detectors are the same rules Deliverd's servers and SDKs run, held to
13
+ the shared vectors in ``packages/conformance``.
14
+
15
+ Patterns find structured identifiers well and people's names hardly at all.
16
+ Plug in a model for names with ``detectors``; :mod:`deliverd_screen.adapters`
17
+ turns a Hugging Face pipeline or a Presidio analyzer into one.
18
+
19
+ A net, not a guarantee. Never tell anyone a text is clean because this allowed
20
+ it; pseudonymised text is still personal data in law.
21
+
22
+ When a person has to decide (``ask``), nobody here can: Deliverd's Data Gate
23
+ takes the policy from your AI register, asks the right person and keeps the
24
+ record. ``deliverd.screen()`` in the ``deliverd`` package takes the same
25
+ ``detectors``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from dataclasses import dataclass, field
31
+ from typing import Callable, Dict, List, Mapping, Optional, Sequence
32
+
33
+ from .data_screen import (
34
+ DATA_KIND_KEYS,
35
+ DATA_KINDS,
36
+ DEFAULT_SCREEN_POLICY,
37
+ SCREEN_CLASSIFICATIONS,
38
+ DetectedSpan,
39
+ Redaction,
40
+ ScreenFinding,
41
+ ScreenTarget,
42
+ ScreenTerm,
43
+ assess_counts,
44
+ describe_counts,
45
+ redact,
46
+ redact_for,
47
+ restore,
48
+ screen_text,
49
+ summarise,
50
+ term_classification_of,
51
+ )
52
+
53
+ __version__ = "0.1.0"
54
+
55
+ #: A detector: a callable from the text to spans (``DetectedSpan`` or dicts of kind, start, end).
56
+ SpanDetector = Callable[[str], Sequence[object]]
57
+
58
+ _ASK_REASON = (
59
+ "A person has to decide whether this may be sent, and nobody can be asked from here. "
60
+ "Deliverd's Data Gate routes the question to one: https://deliverd.dev/docs/developers/screen"
61
+ )
62
+
63
+
64
+ @dataclass
65
+ class Screened:
66
+ """What may be sent, and why."""
67
+
68
+ verdict: str
69
+ #: Whether to send ``text``. False for a deny, and for an ask: nobody here can say yes.
70
+ allowed: bool
71
+ #: The original, the redacted text, or None when nothing may be sent.
72
+ text: Optional[str]
73
+ #: The redacted text, also for an ask, to show whoever decides. None for a deny.
74
+ redacted: Optional[str]
75
+ findings: List[ScreenFinding]
76
+ #: How many of each kind: all Deliverd's Data Gate is ever sent.
77
+ counts: Dict[str, int]
78
+ reasons: List[str]
79
+ #: Placeholder to original value. Yours alone; never sent anywhere.
80
+ replacements: Dict[str, str] = field(default_factory=dict)
81
+
82
+ def restore(self, answer: str) -> str:
83
+ """Put the original values back into text a model wrote using the placeholders."""
84
+ return restore(answer, self.replacements)
85
+
86
+ def __bool__(self) -> bool:
87
+ return self.allowed
88
+
89
+
90
+ def detect(
91
+ text: str,
92
+ terms: Sequence[object] = (),
93
+ detectors: Sequence[SpanDetector] = (),
94
+ spans: Sequence[object] = (),
95
+ ) -> "tuple[List[ScreenFinding], Dict[str, int]]":
96
+ """Find what is in a text: the findings and their counts, no verdict."""
97
+ found = list(spans)
98
+ for d in detectors:
99
+ found.extend(d(text))
100
+ findings = screen_text(text, terms, found)
101
+ return findings, summarise(findings)
102
+
103
+
104
+ def screen(
105
+ text: str,
106
+ *,
107
+ model: str = "the model",
108
+ classification: str = "internal",
109
+ personal_data: bool = False,
110
+ policy: Optional[Mapping[str, object]] = None,
111
+ terms: Sequence[object] = (),
112
+ detectors: Sequence[SpanDetector] = (),
113
+ spans: Sequence[object] = (),
114
+ ) -> Screened:
115
+ """Check text before it goes to a model, and decide what may be sent.
116
+
117
+ ``classification`` and ``personal_data`` say what the model may receive.
118
+ ``allow`` sends it as it is; ``redact`` replaces what it may not receive
119
+ with placeholders, and :meth:`Screened.restore` puts them back in the
120
+ answer; ``ask`` needs a person; ``deny`` sends nothing.
121
+ """
122
+ findings, counts = detect(text, terms, detectors, spans)
123
+ p = dict(policy or {})
124
+ p.pop("unregistered", None)
125
+ a = assess_counts(
126
+ counts,
127
+ ScreenTarget(name=model, approved_classification=classification, personal_data=personal_data),
128
+ p,
129
+ term_classification_of(findings),
130
+ )
131
+ redaction = redact_for(text, findings, a.verdict, a.offending)
132
+ if a.verdict == "allow":
133
+ sent: Optional[str] = text
134
+ elif a.verdict == "redact":
135
+ sent = redaction.text if redaction else None
136
+ else:
137
+ sent = None
138
+ return Screened(
139
+ verdict=a.verdict,
140
+ allowed=sent is not None,
141
+ text=sent,
142
+ redacted=text if a.verdict == "allow" else (redaction.text if redaction else None),
143
+ findings=findings,
144
+ counts=counts,
145
+ reasons=a.reasons + [_ASK_REASON] if a.verdict == "ask" else a.reasons,
146
+ replacements=dict(redaction.replacements) if redaction and a.verdict == "redact" else {},
147
+ )
148
+
149
+
150
+ __all__ = [
151
+ "screen",
152
+ "detect",
153
+ "Screened",
154
+ "SpanDetector",
155
+ "DetectedSpan",
156
+ "ScreenFinding",
157
+ "ScreenTerm",
158
+ "Redaction",
159
+ "screen_text",
160
+ "summarise",
161
+ "redact",
162
+ "restore",
163
+ "describe_counts",
164
+ "DATA_KINDS",
165
+ "DATA_KIND_KEYS",
166
+ "SCREEN_CLASSIFICATIONS",
167
+ "DEFAULT_SCREEN_POLICY",
168
+ ]
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,121 @@
1
+ """Detectors to plug in beside the patterns, for what patterns cannot find:
2
+ people's names above all.
3
+
4
+ Each adapter returns a callable from the text to spans, for
5
+ ``screen(text, detectors=[...])`` here or ``deliverd.screen(text,
6
+ detectors=[...])`` in the ``deliverd`` SDK. This package depends on none of
7
+ them: you install the model or the analyzer and pass it in.
8
+
9
+ Run the model where the text already is. A detector that sends the text to a
10
+ hosted service has sent it somewhere, which is what this check exists to
11
+ prevent.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import Any, Callable, Dict, List, Optional, Sequence
18
+
19
+ from .data_screen import DATA_KINDS, DetectedSpan
20
+
21
+ LabelMap = Callable[[str], Optional[str]]
22
+
23
+
24
+ def default_labels(label: str) -> Optional[str]:
25
+ """What the common label sets mean; None to ignore a label."""
26
+ l = label.lower()
27
+ if re.fullmatch(r"per|person|private_person|name|full_?name|first_?name|last_?name|given_?name|surname|username", l):
28
+ return "person_name"
29
+ if "email" in l:
30
+ return "email"
31
+ if "phone" in l or "telephone" in l:
32
+ return "phone"
33
+ if "iban" in l:
34
+ return "iban"
35
+ if re.search(r"credit_?card|card_?number", l):
36
+ return "payment_card"
37
+ if re.fullmatch(r"ip(_?address)?", l):
38
+ return "ip_address"
39
+ return None
40
+
41
+
42
+ def _field(t: Any, name: str) -> Any:
43
+ return t.get(name) if isinstance(t, dict) else getattr(t, name, None)
44
+
45
+
46
+ def token_classifier(
47
+ classify: Callable[[str], Sequence[Any]],
48
+ min_score: float = 0.5,
49
+ labels: LabelMap = default_labels,
50
+ ) -> Callable[[str], List[DetectedSpan]]:
51
+ """A Hugging Face token-classification pipeline as a detector.
52
+
53
+ from transformers import pipeline
54
+ ner = pipeline("token-classification", model="dslim/bert-base-NER", aggregation_strategy="simple")
55
+ names = token_classifier(ner)
56
+
57
+ Works with anything returning the same dicts (``entity_group`` or
58
+ ``entity``, ``score``, ``start``, ``end``, ``word``), such as Perplexity's
59
+ PII-Tracer or a GLiNER model. Check the model's licence before you ship it.
60
+ Adjacent tokens of one kind become one span.
61
+ """
62
+
63
+ def detect(text: str) -> List[DetectedSpan]:
64
+ spans: List[DetectedSpan] = []
65
+ cursor = 0
66
+ for t in classify(text):
67
+ raw = _field(t, "entity_group") or _field(t, "entity") or _field(t, "label")
68
+ score = _field(t, "score")
69
+ if not raw or (score is not None and score < min_score):
70
+ continue
71
+ kind = labels(re.sub(r"^[BIESL]-", "", str(raw)))
72
+ start, end = _field(t, "start"), _field(t, "end")
73
+ if not isinstance(start, int) or not isinstance(end, int) or end <= start:
74
+ word = re.sub(r"^(##|▁|Ġ)", "", str(_field(t, "word") or "")).strip()
75
+ if not word:
76
+ continue
77
+ start = text.find(word, cursor)
78
+ if start < 0:
79
+ continue
80
+ end = start + len(word)
81
+ cursor = end
82
+ if not kind or kind not in DATA_KINDS:
83
+ continue
84
+ last = spans[-1] if spans else None
85
+ if last and last.kind == kind and re.fullmatch(r"[\s-]?", text[last.end : start]):
86
+ last.end = end
87
+ else:
88
+ spans.append(DetectedSpan(kind=kind, start=start, end=end))
89
+ return spans
90
+
91
+ return detect
92
+
93
+
94
+ _PRESIDIO: Dict[str, str] = {
95
+ "PERSON": "person_name",
96
+ "EMAIL_ADDRESS": "email",
97
+ "PHONE_NUMBER": "phone",
98
+ "IBAN_CODE": "iban",
99
+ "CREDIT_CARD": "payment_card",
100
+ "IP_ADDRESS": "ip_address",
101
+ "US_SSN": "us_ssn",
102
+ "UK_NINO": "uk_national_insurance",
103
+ }
104
+
105
+
106
+ def presidio(analyzer: Any, language: str = "en", min_score: float = 0.5) -> Callable[[str], List[DetectedSpan]]:
107
+ """A Presidio ``AnalyzerEngine`` as a detector, in process.
108
+
109
+ from presidio_analyzer import AnalyzerEngine
110
+ names = presidio(AnalyzerEngine())
111
+ """
112
+
113
+ def detect(text: str) -> List[DetectedSpan]:
114
+ results = analyzer.analyze(text=text, language=language, entities=list(_PRESIDIO))
115
+ return [
116
+ DetectedSpan(kind=_PRESIDIO[r.entity_type], start=r.start, end=r.end)
117
+ for r in results
118
+ if r.entity_type in _PRESIDIO and r.score >= min_score
119
+ ]
120
+
121
+ return detect
@@ -0,0 +1,67 @@
1
+ """deliverd-screen: the check from a terminal, a pre-commit hook or CI.
2
+
3
+ deliverd-screen notes.txt
4
+ cat prompt.txt | deliverd-screen --redact
5
+ deliverd-screen --fail fixtures/*.json
6
+
7
+ Reads files, or standard input when there are none. Nothing is sent anywhere.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import json
14
+ import sys
15
+ from typing import List, Optional, TextIO
16
+
17
+ from . import __version__, detect
18
+ from .data_screen import describe_counts, redact
19
+
20
+
21
+ def main(argv: Optional[List[str]] = None, stdin: TextIO = sys.stdin, stdout: TextIO = sys.stdout, stderr: TextIO = sys.stderr) -> int:
22
+ parser = argparse.ArgumentParser(
23
+ prog="deliverd-screen",
24
+ description="Find personal data, secrets and confidential terms in text before it goes to an AI model. Runs locally; nothing is sent anywhere.",
25
+ )
26
+ parser.add_argument("files", nargs="*", help="files to check; standard input when none")
27
+ parser.add_argument("--redact", action="store_true", help="print the text with what was found replaced by placeholders (one input)")
28
+ parser.add_argument("--json", action="store_true", help="print what was found as JSON: kinds, offsets and counts, never values")
29
+ parser.add_argument("--fail", action="store_true", help="exit 1 when anything is found")
30
+ parser.add_argument("--terms", metavar="FILE", help="your own words not to send, one per line")
31
+ parser.add_argument("-v", "--version", action="version", version=__version__)
32
+ args = parser.parse_args(argv)
33
+ if args.redact and len(args.files) > 1:
34
+ stderr.write("--redact takes one input.\n")
35
+ return 2
36
+
37
+ terms: List[str] = []
38
+ if args.terms:
39
+ with open(args.terms, encoding="utf-8") as f:
40
+ terms = [t.strip() for t in f if t.strip() and not t.strip().startswith("#")]
41
+
42
+ if not args.files or args.files == ["-"]:
43
+ inputs = [("stdin", stdin.read())]
44
+ else:
45
+ inputs = []
46
+ for name in args.files:
47
+ with open(name, encoding="utf-8") as f:
48
+ inputs.append((name, f.read()))
49
+
50
+ found = 0
51
+ report = []
52
+ for name, text in inputs:
53
+ findings, counts = detect(text, terms)
54
+ found += len(findings)
55
+ if args.redact:
56
+ stdout.write(redact(text, findings).text)
57
+ if args.json:
58
+ report.append(
59
+ {"file": name, "counts": counts, "findings": [{"kind": f.kind, "label": f.label, "start": f.start, "end": f.end} for f in findings]}
60
+ )
61
+ elif findings:
62
+ stderr.write("{}: {}\n".format(name, describe_counts(counts)))
63
+ if args.json:
64
+ stdout.write(json.dumps(report[0] if len(report) == 1 else report, indent=2) + "\n")
65
+ elif found == 0 and not args.redact:
66
+ stderr.write("Nothing found.\n")
67
+ return 1 if args.fail and found else 0
@@ -0,0 +1,526 @@
1
+ """Screening what an agent is about to send to an AI model.
2
+
3
+ The Python half of ``packages/core/src/data-screen.ts``, rule for rule. Before
4
+ a prompt, a document or a retrieved record goes to a model, this finds the
5
+ personal data, secrets and confidential terms in it, and decides — against what
6
+ the organisation's AI register approves that model to receive — whether it may
7
+ go as it is, go with the findings replaced by placeholders, go only once a
8
+ person agrees, or not go.
9
+
10
+ **The text need not leave the caller.** Everything here is pure, so
11
+ :meth:`deliverd.Deliverd.screen` runs it where the agent runs and sends
12
+ Deliverd only counts. ``packages/conformance/screening.json`` holds this file
13
+ and the TypeScript one to the same findings, redactions and verdicts.
14
+
15
+ **A net, not a guarantee.** Structured identifiers are found well and names
16
+ hardly at all; pseudonymised text is still personal data in law. Never tell
17
+ anyone a text is clean because this allowed it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import re
23
+ from dataclasses import dataclass, field
24
+ from typing import Dict, List, Mapping, Optional, Sequence
25
+
26
+ #: Ordered: the stricter of two classifications is the later one.
27
+ SCREEN_CLASSIFICATIONS = ("public", "internal", "confidential", "restricted")
28
+ SCREEN_CATEGORIES = ("personal", "financial", "special", "secret", "confidential")
29
+ #: ``allow`` | ``redact`` | ``ask`` | ``deny``
30
+ SCREEN_VERDICTS = ("allow", "redact", "ask", "deny")
31
+
32
+ #: kind → (label, category, placeholder word, whether redacting it removes the concern)
33
+ DATA_KINDS: Dict[str, Dict[str, object]] = {
34
+ "email": {"label": "Email address", "category": "personal", "placeholder": "EMAIL", "redactable": True},
35
+ "phone": {"label": "Phone number", "category": "personal", "placeholder": "PHONE", "redactable": True},
36
+ "iban": {"label": "IBAN", "category": "financial", "placeholder": "IBAN", "redactable": True},
37
+ "payment_card": {"label": "Payment card number", "category": "financial", "placeholder": "CARD", "redactable": True},
38
+ "uk_national_insurance": {"label": "UK National Insurance number", "category": "personal", "placeholder": "NINO", "redactable": True},
39
+ "ie_pps": {"label": "Irish PPS number", "category": "personal", "placeholder": "PPS", "redactable": True},
40
+ "us_ssn": {"label": "US Social Security number", "category": "personal", "placeholder": "SSN", "redactable": True},
41
+ "ip_address": {"label": "IP address", "category": "personal", "placeholder": "IP", "redactable": True},
42
+ "date_of_birth": {"label": "Date of birth", "category": "personal", "placeholder": "DOB", "redactable": True},
43
+ # Never found by a pattern here: only by a model detector the caller plugs in (``spans``).
44
+ "person_name": {"label": "Person's name", "category": "personal", "placeholder": "NAME", "redactable": True},
45
+ "special_category": {"label": "Special-category data", "category": "special", "placeholder": "SPECIAL", "redactable": False},
46
+ "secret": {"label": "Secret or credential", "category": "secret", "placeholder": "SECRET", "redactable": True},
47
+ "term": {"label": "Confidential term", "category": "confidential", "placeholder": "TERM", "redactable": True},
48
+ }
49
+ DATA_KIND_KEYS = tuple(DATA_KINDS)
50
+
51
+ _CATEGORY_CLASSIFICATION = {
52
+ "personal": "confidential",
53
+ "financial": "confidential",
54
+ "confidential": "confidential",
55
+ "special": "restricted",
56
+ "secret": "restricted",
57
+ }
58
+ _PERSONAL_CATEGORIES = {"personal", "financial", "special"}
59
+
60
+ #: Terms past this are ignored, so a pasted list cannot make every screen slow.
61
+ MAX_SCREEN_TERMS = 500
62
+
63
+
64
+ @dataclass
65
+ class ScreenTerm:
66
+ """A word or phrase not to send: a client, a code name. Whole-word, any case."""
67
+
68
+ term: str
69
+ #: Shown to people and used in the placeholder: ``[CLIENT_1]``.
70
+ label: Optional[str] = None
71
+ #: ``confidential`` (default) or ``restricted``.
72
+ classification: Optional[str] = None
73
+
74
+
75
+ @dataclass
76
+ class DetectedSpan:
77
+ """What another detector found, as offsets into the same text."""
78
+
79
+ kind: str
80
+ start: int
81
+ end: int
82
+
83
+
84
+ @dataclass
85
+ class ScreenFinding:
86
+ """Where one finding is in the screened text. The value is never copied."""
87
+
88
+ kind: str
89
+ category: str
90
+ classification: str
91
+ start: int
92
+ end: int
93
+ label: str
94
+ redactable: bool
95
+
96
+
97
+ # ── Detectors ──────────────────────────────────────────────────────────────
98
+
99
+
100
+ def _digits(s: str) -> str:
101
+ return re.sub(r"\D", "", s)
102
+
103
+
104
+ def _luhn(digits: str) -> bool:
105
+ total = 0
106
+ double = False
107
+ for ch in reversed(digits):
108
+ d = ord(ch) - 48
109
+ if double:
110
+ d *= 2
111
+ if d > 9:
112
+ d -= 9
113
+ total += d
114
+ double = not double
115
+ return total % 10 == 0
116
+
117
+
118
+ def _iban_valid(raw: str) -> bool:
119
+ iban = re.sub(r"\s", "", raw).upper()
120
+ if len(iban) < 15 or len(iban) > 34:
121
+ return False
122
+ moved = iban[4:] + iban[:4]
123
+ remainder = 0
124
+ for ch in moved:
125
+ value = str(ord(ch) - 55) if "A" <= ch <= "Z" else ch
126
+ for digit in value:
127
+ remainder = (remainder * 10 + (ord(digit) - 48)) % 97
128
+ return remainder == 1
129
+
130
+
131
+ def _pps_valid(raw: str) -> bool:
132
+ m = re.fullmatch(r"(\d{7})([A-W])([A-IW]?)", raw.upper())
133
+ if not m:
134
+ return False
135
+ total = sum((ord(m.group(1)[i]) - 48) * (8 - i) for i in range(7))
136
+ if m.group(3):
137
+ total += (0 if m.group(3) == "W" else ord(m.group(3)) - 64) * 9
138
+ r = total % 23
139
+ return m.group(2) == ("W" if r == 0 else chr(64 + r))
140
+
141
+
142
+ def _ipv4_valid(raw: str) -> bool:
143
+ parts = [int(p) for p in raw.split(".")]
144
+ if len(parts) != 4 or any(p > 255 for p in parts):
145
+ return False
146
+ # Loopback and the unspecified address identify nobody.
147
+ return parts[0] != 127 and parts[0] != 0
148
+
149
+
150
+ def _card_valid(m: str) -> bool:
151
+ d = _digits(m)
152
+ return 13 <= len(d) <= 19 and _luhn(d)
153
+
154
+
155
+ def _phone_valid(m: str) -> bool:
156
+ return 9 <= len(_digits(m)) <= 15
157
+
158
+
159
+ _A = re.ASCII
160
+ _AI = re.ASCII | re.IGNORECASE
161
+
162
+ #: The same credential patterns the publish scanner blocks a report for.
163
+ _SECRET_PATTERNS = [
164
+ re.compile(r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b", _A),
165
+ re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY(?: BLOCK)?-----", _A),
166
+ re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,255}\b", _A),
167
+ re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b", _A),
168
+ re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}\b", _A),
169
+ re.compile(r"\bsk-ant-[A-Za-z0-9_-]{24,}\b", _A),
170
+ re.compile(r"\bsk-(?:proj-)?[A-Za-z0-9_-]{32,}\b", _A),
171
+ re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b", _A),
172
+ re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b", _A),
173
+ re.compile(r"""\b(?:password|passwd|secret_key|api_key|apikey|auth_token)\s*[:=]\s*["'][^"']{8,}["']""", _AI),
174
+ re.compile(r"\b(?:dlv|dlvk)_[A-Za-z0-9]{24,}\b", _A),
175
+ ]
176
+
177
+ _SPECIAL_PATTERN = re.compile(
178
+ r"\b(?:diagnos(?:is|ed|es)|medical (?:record|history|condition)s?|mental health|prescri(?:bed|ption)s?|HIV|pregnan(?:t|cy)|disabilit(?:y|ies)|sexual orientation|religious belief|ethnic origin|racial origin|trade union member(?:ship)?|biometric|genetic data|criminal (?:record|conviction|offence)s?)\b",
179
+ _AI,
180
+ )
181
+
182
+ # (kind, pattern, validator, group)
183
+ _DETECTORS = [
184
+ *[("secret", p, None, 0) for p in _SECRET_PATTERNS],
185
+ ("iban", re.compile(r"\b[A-Z]{2}\d{2}(?: ?[A-Z0-9]{4}){2,7}(?: ?[A-Z0-9]{1,4})?\b", _A), _iban_valid, 0),
186
+ ("payment_card", re.compile(r"\b[2-6]\d{3}(?:[ -]?\d{4}){2}[ -]?\d{1,7}\b", _A), _card_valid, 0),
187
+ ("us_ssn", re.compile(r"\b(?!000|666|9\d\d)\d{3}-(?!00)\d{2}-(?!0000)\d{4}\b", _A), None, 0),
188
+ (
189
+ "uk_national_insurance",
190
+ re.compile(r"\b(?!BG|GB|NK|KN|TN|NT|ZZ)[A-CEGHJ-PR-TW-Z][A-CEGHJ-NPR-TW-Z] ?\d{2} ?\d{2} ?\d{2} ?[A-D]\b", _A),
191
+ None,
192
+ 0,
193
+ ),
194
+ ("ie_pps", re.compile(r"\b\d{7}[A-W][A-IW]?\b", _A), _pps_valid, 0),
195
+ ("email", re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(?:\.[A-Za-z0-9-]+)*\.[A-Za-z]{2,}\b", _A), None, 0),
196
+ (
197
+ "date_of_birth",
198
+ re.compile(
199
+ r"\b(?:date of birth|d\.?o\.?b\.?|born(?: on)?)\s*[:\-]?\s*(\d{1,2}[/.\-]\d{1,2}[/.\-]\d{2,4}|\d{4}-\d{2}-\d{2}|\d{1,2} (?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]* \d{4})",
200
+ _AI,
201
+ ),
202
+ None,
203
+ 1,
204
+ ),
205
+ (
206
+ "phone",
207
+ re.compile(r"(?<![\w+])(?:\+\d{1,3}[ .-]?(?:\(0\)[ .-]?)?|\(?0)\d{1,4}\)?(?:[ .-]?\d{2,4}){2,4}(?!\w)", _A),
208
+ _phone_valid,
209
+ 0,
210
+ ),
211
+ ("ip_address", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b", _A), _ipv4_valid, 0),
212
+ ("special_category", _SPECIAL_PATTERN, None, 0),
213
+ ]
214
+
215
+ #: Earlier wins an overlap: a card number is not also a phone number.
216
+ _PRIORITY = {
217
+ k: i
218
+ for i, k in enumerate(
219
+ [
220
+ "secret",
221
+ "iban",
222
+ "payment_card",
223
+ "us_ssn",
224
+ "uk_national_insurance",
225
+ "ie_pps",
226
+ "email",
227
+ "date_of_birth",
228
+ "phone",
229
+ "ip_address",
230
+ "term",
231
+ "person_name",
232
+ "special_category",
233
+ ]
234
+ )
235
+ }
236
+
237
+
238
+ def _term_of(t: object) -> ScreenTerm:
239
+ if isinstance(t, ScreenTerm):
240
+ return t
241
+ if isinstance(t, Mapping):
242
+ return ScreenTerm(term=str(t.get("term", "")), label=t.get("label"), classification=t.get("classification"))
243
+ return ScreenTerm(term=str(t))
244
+
245
+
246
+ def _span_of(s: object) -> Optional[DetectedSpan]:
247
+ if isinstance(s, DetectedSpan):
248
+ return s
249
+ if isinstance(s, Mapping):
250
+ try:
251
+ return DetectedSpan(kind=str(s.get("kind", "")), start=s["start"], end=s["end"])
252
+ except KeyError:
253
+ return None
254
+ return None
255
+
256
+
257
+ def screen_text(text: str, terms: Sequence[object] = (), spans: Sequence[object] = ()) -> List[ScreenFinding]:
258
+ """Find what is in a text. Each character belongs to at most one finding.
259
+
260
+ ``terms`` are :class:`ScreenTerm`, dicts with the same keys, or strings.
261
+ ``spans`` are another detector's findings — a named-entity model, a DLP
262
+ service — as :class:`DetectedSpan` or dicts of ``kind``, ``start`` and
263
+ ``end``, held to the same rules; one outside the text or of no known kind
264
+ is dropped.
265
+ """
266
+ raw: List[ScreenFinding] = []
267
+
268
+ def add(kind: str, start: int, end: int, label: Optional[str] = None, classification: Optional[str] = None) -> None:
269
+ spec = DATA_KINDS[kind]
270
+ raw.append(
271
+ ScreenFinding(
272
+ kind=kind,
273
+ category=str(spec["category"]),
274
+ classification=classification or _CATEGORY_CLASSIFICATION[str(spec["category"])],
275
+ start=start,
276
+ end=end,
277
+ label=label or str(spec["label"]),
278
+ redactable=bool(spec["redactable"]),
279
+ )
280
+ )
281
+
282
+ for kind, pattern, valid, group in _DETECTORS:
283
+ for m in pattern.finditer(text):
284
+ value = m.group(group) if group else m.group(0)
285
+ if not value or (valid is not None and not valid(value)):
286
+ continue
287
+ start = m.start() + m.group(0).rfind(value) if group else m.start()
288
+ add(kind, start, start + len(value))
289
+
290
+ for t in [_term_of(t) for t in list(terms)[:MAX_SCREEN_TERMS]]:
291
+ term = t.term.strip()
292
+ if len(term) < 2:
293
+ continue
294
+ pattern = re.compile(r"(?<![^\W_])" + re.escape(term) + r"(?![^\W_])", re.IGNORECASE)
295
+ for m in pattern.finditer(text):
296
+ add("term", m.start(), m.end(), (t.label or "").strip() or None, t.classification or "confidential")
297
+
298
+ for sp in (_span_of(s) for s in spans):
299
+ if sp is None or sp.kind not in DATA_KINDS or sp.kind == "term":
300
+ continue
301
+ if not isinstance(sp.start, int) or not isinstance(sp.end, int) or isinstance(sp.start, bool) or isinstance(sp.end, bool):
302
+ continue
303
+ if sp.start < 0 or sp.end > len(text) or sp.end <= sp.start:
304
+ continue
305
+ add(sp.kind, sp.start, sp.end)
306
+
307
+ raw.sort(key=lambda f: (_PRIORITY[f.kind], -(f.end - f.start), f.start))
308
+ kept: List[ScreenFinding] = []
309
+ for f in raw:
310
+ if any(f.start < k.end and k.start < f.end for k in kept):
311
+ continue
312
+ kept.append(f)
313
+ return sorted(kept, key=lambda f: f.start)
314
+
315
+
316
+ def summarise(findings: Sequence[ScreenFinding]) -> Dict[str, int]:
317
+ """Counts only: what goes over the wire when the text stays where it is."""
318
+ counts: Dict[str, int] = {}
319
+ for f in findings:
320
+ counts[f.kind] = counts.get(f.kind, 0) + 1
321
+ return counts
322
+
323
+
324
+ # ── Redaction ──────────────────────────────────────────────────────────────
325
+
326
+
327
+ @dataclass
328
+ class Redaction:
329
+ text: str
330
+ #: Placeholder → original value. Stays with the caller; never sent anywhere.
331
+ replacements: Dict[str, str] = field(default_factory=dict)
332
+
333
+
334
+ def _placeholder_word(f: ScreenFinding) -> str:
335
+ if f.kind == "term" and f.label != DATA_KINDS["term"]["label"]:
336
+ word = re.sub(r"^_|_$", "", re.sub(r"[^A-Z0-9]+", "_", f.label.upper()))[:24]
337
+ return word or "TERM"
338
+ return str(DATA_KINDS[f.kind]["placeholder"])
339
+
340
+
341
+ def redact(text: str, findings: Sequence[ScreenFinding]) -> Redaction:
342
+ """Replace each redactable finding with a placeholder, one per distinct value."""
343
+ replacements: Dict[str, str] = {}
344
+ by_value: Dict[str, str] = {}
345
+ counters: Dict[str, int] = {}
346
+ out = []
347
+ at = 0
348
+ for f in sorted(findings, key=lambda f: f.start):
349
+ if not f.redactable or f.start < at:
350
+ continue
351
+ value = text[f.start : f.end]
352
+ key = "{}\0{}".format(f.kind, value.lower())
353
+ placeholder = by_value.get(key)
354
+ if placeholder is None:
355
+ word = _placeholder_word(f)
356
+ counters[word] = counters.get(word, 0) + 1
357
+ placeholder = "[{}_{}]".format(word, counters[word])
358
+ by_value[key] = placeholder
359
+ replacements[placeholder] = value
360
+ out.append(text[at : f.start] + placeholder)
361
+ at = f.end
362
+ out.append(text[at:])
363
+ return Redaction(text="".join(out), replacements=replacements)
364
+
365
+
366
+ def restore(text: str, replacements: Mapping[str, str]) -> str:
367
+ """Put the original values back into text a model wrote using the placeholders."""
368
+ keys = sorted(replacements, key=len, reverse=True)
369
+ if not keys:
370
+ return text
371
+ pattern = re.compile("|".join(re.escape(k) for k in keys))
372
+ return pattern.sub(lambda m: replacements.get(m.group(0), m.group(0)), text)
373
+
374
+
375
+ # ── Deciding ───────────────────────────────────────────────────────────────
376
+
377
+
378
+ @dataclass
379
+ class ScreenTarget:
380
+ """What the destination model is approved to receive: its AI register record."""
381
+
382
+ name: str
383
+ approved_classification: Optional[str]
384
+ personal_data: bool
385
+ active: bool = True
386
+
387
+
388
+ @dataclass
389
+ class ScreenAssessment:
390
+ verdict: str
391
+ classification: str
392
+ personal_data: bool
393
+ categories: List[str]
394
+ offending: List[str]
395
+ reasons: List[str]
396
+
397
+
398
+ DEFAULT_SCREEN_POLICY: Dict[str, object] = {
399
+ "secrets": "deny",
400
+ "beyondApproval": "redact",
401
+ "notRedactable": "ask",
402
+ "unregistered": {"approvedClassification": "internal", "personalData": False},
403
+ }
404
+
405
+ _VERDICT_RANK = {"allow": 0, "redact": 1, "ask": 2, "deny": 3}
406
+
407
+
408
+ def _stricter(a: str, b: str) -> str:
409
+ return b if _VERDICT_RANK[b] > _VERDICT_RANK[a] else a
410
+
411
+
412
+ def _rank(c: str) -> int:
413
+ return SCREEN_CLASSIFICATIONS.index(c)
414
+
415
+
416
+ def describe_counts(counts: Mapping[str, int]) -> str:
417
+ """``2 email addresses and 1 IBAN``."""
418
+ parts = []
419
+ for k in DATA_KIND_KEYS:
420
+ n = counts.get(k) or 0
421
+ if n <= 0:
422
+ continue
423
+ label = str(DATA_KINDS[k]["label"])
424
+ s = "" if n == 1 else "s"
425
+ if k == "email":
426
+ parts.append("{} email address{}".format(n, "" if n == 1 else "es"))
427
+ elif k == "special_category":
428
+ parts.append("{} mention{} of special-category data".format(n, s))
429
+ elif k == "secret":
430
+ parts.append("{} secret{} or credential{}".format(n, s, s))
431
+ elif k == "term":
432
+ parts.append("{} confidential term{}".format(n, s))
433
+ elif k == "person_name":
434
+ parts.append("{} {}".format(n, "person's name" if n == 1 else "people's names"))
435
+ elif re.match(r"^[A-Z]{2,}", label):
436
+ parts.append("{} {}{}".format(n, label, s))
437
+ else:
438
+ parts.append("{} {}{}".format(n, label.lower(), s))
439
+ if len(parts) <= 1:
440
+ return parts[0] if parts else "nothing"
441
+ return "{} and {}".format(", ".join(parts[:-1]), parts[-1])
442
+
443
+
444
+ def assess_counts(
445
+ counts: Mapping[str, int],
446
+ target: Optional[ScreenTarget],
447
+ policy: Optional[Mapping[str, object]] = None,
448
+ term_classification: str = "confidential",
449
+ ) -> ScreenAssessment:
450
+ """Decide from counts alone, the same way the server does."""
451
+ p = dict(DEFAULT_SCREEN_POLICY)
452
+ p.update(policy or {})
453
+ present = [k for k in DATA_KIND_KEYS if (counts.get(k) or 0) > 0]
454
+ categories = [c for c in SCREEN_CATEGORIES if any(DATA_KINDS[k]["category"] == c for k in present)]
455
+
456
+ def class_of(k: str) -> str:
457
+ return term_classification if k == "term" else _CATEGORY_CLASSIFICATION[str(DATA_KINDS[k]["category"])]
458
+
459
+ classification = "public"
460
+ for k in present:
461
+ if _rank(class_of(k)) > _rank(classification):
462
+ classification = class_of(k)
463
+ personal = any(c in _PERSONAL_CATEGORIES for c in categories)
464
+
465
+ if target is not None and not target.active:
466
+ return ScreenAssessment(
467
+ "deny",
468
+ classification,
469
+ personal,
470
+ categories,
471
+ present,
472
+ ["{} is not an active system in the AI register, so nothing should be sent to it.".format(target.name)],
473
+ )
474
+ reasons: List[str] = []
475
+ offending: List[str] = []
476
+ verdict = "allow"
477
+ if not present:
478
+ return ScreenAssessment(verdict, classification, personal, categories, offending, reasons)
479
+
480
+ unregistered = p["unregistered"]
481
+ assert isinstance(unregistered, Mapping)
482
+ name = target.name if target is not None else "an AI system that is not in the register"
483
+ approved = (
484
+ (target.approved_classification or str(unregistered["approvedClassification"]))
485
+ if target is not None
486
+ else str(unregistered["approvedClassification"])
487
+ )
488
+ personal_approved = target.personal_data if target is not None else bool(unregistered["personalData"])
489
+
490
+ secrets = counts.get("secret") or 0
491
+ if secrets > 0:
492
+ verdict = _stricter(verdict, str(p["secrets"]))
493
+ offending.append("secret")
494
+ reasons.append("It contains {}, which should never be sent to a model.".format(describe_counts({"secret": secrets})))
495
+
496
+ for k in present:
497
+ if k == "secret":
498
+ continue
499
+ too_sensitive = _rank(class_of(k)) > _rank(approved)
500
+ personal_not_approved = DATA_KINDS[k]["category"] != "confidential" and not personal_approved
501
+ if not too_sensitive and not personal_not_approved:
502
+ continue
503
+ action = str(p["beyondApproval"] if DATA_KINDS[k]["redactable"] else p["notRedactable"])
504
+ verdict = _stricter(verdict, action)
505
+ offending.append(k)
506
+ why = (
507
+ "which is not approved to receive personal data"
508
+ if personal_not_approved
509
+ else "which is approved for {} data at most".format(approved)
510
+ )
511
+ reasons.append("It contains {}, and goes to {}, {}.".format(describe_counts({k: counts[k]}), name, why))
512
+
513
+ return ScreenAssessment(verdict, classification, personal, categories, offending, reasons)
514
+
515
+
516
+ def redact_for(text: str, findings: Sequence[ScreenFinding], verdict: str, offending: Sequence[str]) -> Optional[Redaction]:
517
+ """The text with only the findings behind a verdict replaced. ``None`` for a deny."""
518
+ if verdict == "deny":
519
+ return None
520
+ wanted = set(offending)
521
+ return redact(text, [f for f in findings if f.kind in wanted])
522
+
523
+
524
+ def term_classification_of(findings: Sequence[ScreenFinding]) -> str:
525
+ """The strictest classification among the terms found."""
526
+ return "restricted" if any(f.kind == "term" and f.classification == "restricted" for f in findings) else "confidential"
File without changes