crprotocol 2.0.0__py3-none-any.whl
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.
- crp/__init__.py +126 -0
- crp/__main__.py +8 -0
- crp/_typing.py +27 -0
- crp/_version.py +5 -0
- crp/adapters.py +31 -0
- crp/advanced/__init__.py +40 -0
- crp/advanced/auto_ingest.py +400 -0
- crp/advanced/cqs.py +235 -0
- crp/advanced/cross_window.py +477 -0
- crp/advanced/curator.py +265 -0
- crp/advanced/feedback.py +146 -0
- crp/advanced/hierarchical.py +211 -0
- crp/advanced/meta_learning.py +401 -0
- crp/advanced/parallel.py +98 -0
- crp/advanced/review_cycle.py +329 -0
- crp/advanced/scale_mode.py +129 -0
- crp/advanced/source_grounding.py +207 -0
- crp/ckf/__init__.py +35 -0
- crp/ckf/community.py +377 -0
- crp/ckf/fabric.py +445 -0
- crp/ckf/gc.py +175 -0
- crp/ckf/graph_walk.py +87 -0
- crp/ckf/merge.py +133 -0
- crp/ckf/pattern_query.py +122 -0
- crp/ckf/pubsub.py +128 -0
- crp/ckf/semantic.py +207 -0
- crp/cli/__init__.py +7 -0
- crp/cli/main.py +329 -0
- crp/cli/sidecar.py +929 -0
- crp/cli/startup.py +272 -0
- crp/continuation/__init__.py +103 -0
- crp/continuation/completion.py +348 -0
- crp/continuation/degradation.py +157 -0
- crp/continuation/document_map.py +160 -0
- crp/continuation/flow.py +109 -0
- crp/continuation/gap.py +419 -0
- crp/continuation/manager.py +484 -0
- crp/continuation/quality_monitor.py +179 -0
- crp/continuation/stitch.py +419 -0
- crp/continuation/trigger.py +142 -0
- crp/continuation/voice.py +157 -0
- crp/core/__init__.py +69 -0
- crp/core/batch.py +77 -0
- crp/core/circuit_breaker.py +116 -0
- crp/core/config.py +377 -0
- crp/core/context_tools.py +540 -0
- crp/core/dispatch_router.py +3977 -0
- crp/core/errors.py +128 -0
- crp/core/extraction_facade.py +384 -0
- crp/core/facilitator.py +713 -0
- crp/core/idempotency.py +215 -0
- crp/core/orchestrator.py +1435 -0
- crp/core/relay_strategies.py +613 -0
- crp/core/security_manager.py +140 -0
- crp/core/session.py +134 -0
- crp/core/task_intent.py +36 -0
- crp/core/window.py +363 -0
- crp/envelope/__init__.py +30 -0
- crp/envelope/builder.py +288 -0
- crp/envelope/decomposer.py +236 -0
- crp/envelope/formatter.py +168 -0
- crp/envelope/packer.py +211 -0
- crp/envelope/reranker.py +209 -0
- crp/envelope/scoring.py +310 -0
- crp/extraction/__init__.py +45 -0
- crp/extraction/complexity.py +96 -0
- crp/extraction/contradiction.py +132 -0
- crp/extraction/pipeline.py +360 -0
- crp/extraction/quality_gate.py +237 -0
- crp/extraction/stage1_regex.py +173 -0
- crp/extraction/stage2_statistical.py +244 -0
- crp/extraction/stage3_gliner.py +210 -0
- crp/extraction/stage4_uie.py +183 -0
- crp/extraction/stage5_discourse.py +175 -0
- crp/extraction/stage6_llm.py +178 -0
- crp/extraction/structured_output.py +219 -0
- crp/extraction/types.py +299 -0
- crp/license_guard.py +722 -0
- crp/observability/__init__.py +30 -0
- crp/observability/audit.py +118 -0
- crp/observability/events.py +233 -0
- crp/observability/metrics.py +264 -0
- crp/observability/quality.py +135 -0
- crp/observability/structured_logging.py +81 -0
- crp/observability/telemetry.py +117 -0
- crp/provenance/__init__.py +314 -0
- crp/provenance/_embeddings.py +97 -0
- crp/provenance/_types.py +378 -0
- crp/provenance/attribution_scorer.py +252 -0
- crp/provenance/claim_detector.py +229 -0
- crp/provenance/contradiction_detector.py +243 -0
- crp/provenance/distortion_detector.py +397 -0
- crp/provenance/entailment_verifier.py +358 -0
- crp/provenance/fabrication_detector.py +203 -0
- crp/provenance/hallucination_scorer.py +320 -0
- crp/provenance/omission_analyzer.py +106 -0
- crp/provenance/provenance_chain.py +205 -0
- crp/provenance/report_generator.py +440 -0
- crp/providers/__init__.py +43 -0
- crp/providers/anthropic.py +270 -0
- crp/providers/base.py +135 -0
- crp/providers/custom.py +63 -0
- crp/providers/diagnostic.py +251 -0
- crp/providers/llamacpp.py +224 -0
- crp/providers/manager.py +139 -0
- crp/providers/ollama.py +243 -0
- crp/providers/openai.py +628 -0
- crp/providers/tokenizers.py +48 -0
- crp/py.typed +0 -0
- crp/resources/__init__.py +53 -0
- crp/resources/adaptive_allocator.py +525 -0
- crp/resources/cost_model.py +388 -0
- crp/resources/overhead_manager.py +217 -0
- crp/resources/resource_manager.py +262 -0
- crp/schemas/__init__.py +20 -0
- crp/schemas/cost-estimate.json +33 -0
- crp/schemas/crp-error.json +43 -0
- crp/schemas/envelope-preview.json +40 -0
- crp/schemas/persisted-state-header.json +27 -0
- crp/schemas/quality-report.json +94 -0
- crp/schemas/session-handle.json +33 -0
- crp/schemas/session-status.json +57 -0
- crp/schemas/stream-event.json +18 -0
- crp/schemas/task-intent.json +42 -0
- crp/security/__init__.py +93 -0
- crp/security/audit_trail.py +392 -0
- crp/security/binding.py +192 -0
- crp/security/compliance.py +813 -0
- crp/security/consent.py +593 -0
- crp/security/embedding_defense.py +161 -0
- crp/security/encryption.py +202 -0
- crp/security/injection.py +335 -0
- crp/security/integrity.py +267 -0
- crp/security/privacy.py +662 -0
- crp/security/quarantine.py +249 -0
- crp/security/rbac.py +221 -0
- crp/security/validation.py +164 -0
- crp/state/__init__.py +31 -0
- crp/state/cold_storage.py +258 -0
- crp/state/compaction.py +263 -0
- crp/state/critical_state.py +104 -0
- crp/state/event_log.py +313 -0
- crp/state/fact.py +189 -0
- crp/state/serialization.py +189 -0
- crp/state/session_cleanup.py +77 -0
- crp/state/snapshot.py +290 -0
- crp/state/warm_store.py +346 -0
- crprotocol-2.0.0.dist-info/METADATA +1295 -0
- crprotocol-2.0.0.dist-info/RECORD +153 -0
- crprotocol-2.0.0.dist-info/WHEEL +4 -0
- crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
- crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
- crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
crp/security/privacy.py
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Privacy controls — data classification, PII detection, retention, erasure (§7.12).
|
|
4
|
+
|
|
5
|
+
Implements:
|
|
6
|
+
- Data sensitivity classification (PUBLIC → CRITICAL)
|
|
7
|
+
- PII detection with configurable patterns
|
|
8
|
+
- Data minimization enforcement
|
|
9
|
+
- Retention policies with automatic expiry
|
|
10
|
+
- Right to erasure (GDPR Article 17 / EU AI Act transparency)
|
|
11
|
+
- Data lineage tracking
|
|
12
|
+
|
|
13
|
+
EU AI Act: Art. 10 (data governance), Art. 12 (record-keeping), Art. 13 (transparency)
|
|
14
|
+
ISO 42001: A.6.2.4 (impact assessment), A.6.2.6 (data management), A.6.2.7 (data subject rights)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from enum import IntEnum
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("crp.security.privacy")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Data classification levels
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class DataClassification(IntEnum):
|
|
36
|
+
"""Data sensitivity classification (§7.12.1).
|
|
37
|
+
|
|
38
|
+
Higher values = more sensitive. Controls encryption, retention,
|
|
39
|
+
access requirements, and audit verbosity.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
PUBLIC = 0 # Non-sensitive: documentation, public facts
|
|
43
|
+
INTERNAL = 1 # Internal business context (default for all ingested text)
|
|
44
|
+
CONFIDENTIAL = 2 # Contains business-sensitive information
|
|
45
|
+
RESTRICTED = 3 # Contains PII or regulated data
|
|
46
|
+
CRITICAL = 4 # Contains credentials, secrets, or highly sensitive PII
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# Classification → minimum security requirements
|
|
50
|
+
_CLASSIFICATION_REQUIREMENTS: dict[DataClassification, dict[str, Any]] = {
|
|
51
|
+
DataClassification.PUBLIC: {
|
|
52
|
+
"encrypt_at_rest": False,
|
|
53
|
+
"max_retention_hours": 0, # 0 = no limit
|
|
54
|
+
"audit_access": False,
|
|
55
|
+
"require_consent": False,
|
|
56
|
+
"allow_export": True,
|
|
57
|
+
},
|
|
58
|
+
DataClassification.INTERNAL: {
|
|
59
|
+
"encrypt_at_rest": True,
|
|
60
|
+
"max_retention_hours": 720, # 30 days
|
|
61
|
+
"audit_access": False,
|
|
62
|
+
"require_consent": False,
|
|
63
|
+
"allow_export": True,
|
|
64
|
+
},
|
|
65
|
+
DataClassification.CONFIDENTIAL: {
|
|
66
|
+
"encrypt_at_rest": True,
|
|
67
|
+
"max_retention_hours": 168, # 7 days
|
|
68
|
+
"audit_access": True,
|
|
69
|
+
"require_consent": False,
|
|
70
|
+
"allow_export": True,
|
|
71
|
+
},
|
|
72
|
+
DataClassification.RESTRICTED: {
|
|
73
|
+
"encrypt_at_rest": True,
|
|
74
|
+
"max_retention_hours": 24, # 24 hours
|
|
75
|
+
"audit_access": True,
|
|
76
|
+
"require_consent": True,
|
|
77
|
+
"allow_export": False, # Must be explicitly authorized
|
|
78
|
+
},
|
|
79
|
+
DataClassification.CRITICAL: {
|
|
80
|
+
"encrypt_at_rest": True,
|
|
81
|
+
"max_retention_hours": 1, # 1 hour — process and discard
|
|
82
|
+
"audit_access": True,
|
|
83
|
+
"require_consent": True,
|
|
84
|
+
"allow_export": False,
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def classification_requirements(level: DataClassification) -> dict[str, Any]:
|
|
90
|
+
"""Return minimum security requirements for a data classification level."""
|
|
91
|
+
return _CLASSIFICATION_REQUIREMENTS[level].copy()
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# PII detection patterns
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
# Compiled regex patterns for common PII types.
|
|
99
|
+
# These are intentionally conservative (high precision, lower recall)
|
|
100
|
+
# to avoid false positives that would degrade user experience.
|
|
101
|
+
_PII_PATTERNS: list[tuple[str, str, re.Pattern[str]]] = [
|
|
102
|
+
(
|
|
103
|
+
"email",
|
|
104
|
+
"Email address",
|
|
105
|
+
re.compile(
|
|
106
|
+
r"\b[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}\b",
|
|
107
|
+
),
|
|
108
|
+
),
|
|
109
|
+
(
|
|
110
|
+
"phone_international",
|
|
111
|
+
"International phone number",
|
|
112
|
+
re.compile(
|
|
113
|
+
r"(?<!\d)\+\d{1,3}[\s\-]?\(?\d{1,4}\)?[\s\-]?\d{3,4}[\s\-]?\d{3,4}(?!\d)",
|
|
114
|
+
),
|
|
115
|
+
),
|
|
116
|
+
(
|
|
117
|
+
"credit_card",
|
|
118
|
+
"Credit card number",
|
|
119
|
+
re.compile(
|
|
120
|
+
r"\b(?:4\d{3}|5[1-5]\d{2}|3[47]\d{2}|6(?:011|5\d{2}))"
|
|
121
|
+
r"[\s\-]?\d{4}[\s\-]?\d{4}[\s\-]?\d{4}\b",
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
(
|
|
125
|
+
"ssn_us",
|
|
126
|
+
"US Social Security Number",
|
|
127
|
+
re.compile(
|
|
128
|
+
r"\b\d{3}[\s\-]\d{2}[\s\-]\d{4}\b",
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
(
|
|
132
|
+
"ip_address",
|
|
133
|
+
"IP address (v4)",
|
|
134
|
+
re.compile(
|
|
135
|
+
r"\b(?:(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\.){3}"
|
|
136
|
+
r"(?:25[0-5]|2[0-4]\d|[01]?\d\d?)\b",
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
(
|
|
140
|
+
"iban",
|
|
141
|
+
"International Bank Account Number",
|
|
142
|
+
re.compile(
|
|
143
|
+
r"\b[A-Z]{2}\d{2}[\s]?[A-Z0-9]{4}[\s]?(?:[A-Z0-9]{4}[\s]?){2,7}[A-Z0-9]{1,4}\b",
|
|
144
|
+
),
|
|
145
|
+
),
|
|
146
|
+
(
|
|
147
|
+
"passport",
|
|
148
|
+
"Passport number pattern",
|
|
149
|
+
re.compile(
|
|
150
|
+
r"\b[A-Z]{1,2}\d{6,9}\b",
|
|
151
|
+
),
|
|
152
|
+
),
|
|
153
|
+
(
|
|
154
|
+
"api_key_generic",
|
|
155
|
+
"Generic API key / secret",
|
|
156
|
+
re.compile(
|
|
157
|
+
r"\b(?:sk|pk|api|key|secret|token|bearer)[_\-]"
|
|
158
|
+
r"[a-zA-Z0-9]{20,}\b",
|
|
159
|
+
re.IGNORECASE,
|
|
160
|
+
),
|
|
161
|
+
),
|
|
162
|
+
(
|
|
163
|
+
"aws_key",
|
|
164
|
+
"AWS access key",
|
|
165
|
+
re.compile(
|
|
166
|
+
r"\bAKIA[0-9A-Z]{16}\b",
|
|
167
|
+
),
|
|
168
|
+
),
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@dataclass
|
|
173
|
+
class PIIDetection:
|
|
174
|
+
"""Result of PII scan on a piece of text."""
|
|
175
|
+
|
|
176
|
+
pii_type: str # e.g. "email", "credit_card"
|
|
177
|
+
description: str # Human-readable
|
|
178
|
+
position: int # Character offset
|
|
179
|
+
length: int # Length of matched text
|
|
180
|
+
text_hash: str # SHA-256 hash of matched text (not the text itself)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass
|
|
184
|
+
class PIIScanResult:
|
|
185
|
+
"""Aggregate result of PII scanning."""
|
|
186
|
+
|
|
187
|
+
detections: list[PIIDetection] = field(default_factory=list)
|
|
188
|
+
scanned_length: int = 0
|
|
189
|
+
scan_time_ms: float = 0.0
|
|
190
|
+
|
|
191
|
+
@property
|
|
192
|
+
def has_pii(self) -> bool:
|
|
193
|
+
return len(self.detections) > 0
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def pii_types_found(self) -> set[str]:
|
|
197
|
+
return {d.pii_type for d in self.detections}
|
|
198
|
+
|
|
199
|
+
@property
|
|
200
|
+
def highest_classification(self) -> DataClassification:
|
|
201
|
+
"""Return the highest data classification implied by detected PII."""
|
|
202
|
+
if not self.detections:
|
|
203
|
+
return DataClassification.INTERNAL
|
|
204
|
+
types = self.pii_types_found
|
|
205
|
+
if types & {"credit_card", "ssn_us", "aws_key", "api_key_generic"}:
|
|
206
|
+
return DataClassification.CRITICAL
|
|
207
|
+
if types & {"iban", "passport"}:
|
|
208
|
+
return DataClassification.RESTRICTED
|
|
209
|
+
if types & {"email", "phone_international", "ip_address"}:
|
|
210
|
+
return DataClassification.CONFIDENTIAL
|
|
211
|
+
return DataClassification.INTERNAL
|
|
212
|
+
|
|
213
|
+
def to_dict(self) -> dict[str, Any]:
|
|
214
|
+
return {
|
|
215
|
+
"has_pii": self.has_pii,
|
|
216
|
+
"detection_count": len(self.detections),
|
|
217
|
+
"pii_types": sorted(self.pii_types_found),
|
|
218
|
+
"highest_classification": self.highest_classification.name,
|
|
219
|
+
"scanned_length": self.scanned_length,
|
|
220
|
+
"scan_time_ms": round(self.scan_time_ms, 2),
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class PIIScanner:
|
|
225
|
+
"""Detect PII patterns in text (§7.12.2).
|
|
226
|
+
|
|
227
|
+
IMPORTANT: This scanner is advisory. It NEVER modifies, redacts, or blocks
|
|
228
|
+
content (Axiom 9 — output integrity). It reports findings for the user
|
|
229
|
+
to act upon.
|
|
230
|
+
|
|
231
|
+
Usage::
|
|
232
|
+
|
|
233
|
+
scanner = PIIScanner()
|
|
234
|
+
result = scanner.scan("Contact john@example.com for details.")
|
|
235
|
+
if result.has_pii:
|
|
236
|
+
print(f"Found PII: {result.pii_types_found}")
|
|
237
|
+
"""
|
|
238
|
+
|
|
239
|
+
def __init__(
|
|
240
|
+
self,
|
|
241
|
+
*,
|
|
242
|
+
extra_patterns: list[tuple[str, str, re.Pattern[str]]] | None = None,
|
|
243
|
+
disabled_types: set[str] | None = None,
|
|
244
|
+
) -> None:
|
|
245
|
+
self._patterns = list(_PII_PATTERNS)
|
|
246
|
+
if extra_patterns:
|
|
247
|
+
self._patterns.extend(extra_patterns)
|
|
248
|
+
self._disabled = disabled_types or set()
|
|
249
|
+
|
|
250
|
+
def scan(self, text: str) -> PIIScanResult:
|
|
251
|
+
"""Scan text for PII patterns.
|
|
252
|
+
|
|
253
|
+
Returns PIIScanResult with detections. Text of matches is NEVER
|
|
254
|
+
stored — only SHA-256 hashes for audit trail purposes.
|
|
255
|
+
"""
|
|
256
|
+
t0 = time.monotonic()
|
|
257
|
+
detections: list[PIIDetection] = []
|
|
258
|
+
|
|
259
|
+
for pii_type, description, pattern in self._patterns:
|
|
260
|
+
if pii_type in self._disabled:
|
|
261
|
+
continue
|
|
262
|
+
for match in pattern.finditer(text):
|
|
263
|
+
matched_text = match.group()
|
|
264
|
+
detections.append(
|
|
265
|
+
PIIDetection(
|
|
266
|
+
pii_type=pii_type,
|
|
267
|
+
description=description,
|
|
268
|
+
position=match.start(),
|
|
269
|
+
length=len(matched_text),
|
|
270
|
+
# Hash the matched text — NEVER store raw PII
|
|
271
|
+
text_hash=hashlib.sha256(
|
|
272
|
+
matched_text.encode("utf-8")
|
|
273
|
+
).hexdigest()[:16],
|
|
274
|
+
)
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
elapsed_ms = (time.monotonic() - t0) * 1000
|
|
278
|
+
|
|
279
|
+
if detections:
|
|
280
|
+
logger.info(
|
|
281
|
+
"PII scan: %d detections across %d types in %d chars (%.1fms)",
|
|
282
|
+
len(detections),
|
|
283
|
+
len({d.pii_type for d in detections}),
|
|
284
|
+
len(text),
|
|
285
|
+
elapsed_ms,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
return PIIScanResult(
|
|
289
|
+
detections=detections,
|
|
290
|
+
scanned_length=len(text),
|
|
291
|
+
scan_time_ms=elapsed_ms,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
# Data retention policy
|
|
297
|
+
# ---------------------------------------------------------------------------
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
@dataclass
|
|
301
|
+
class RetentionPolicy:
|
|
302
|
+
"""Data retention policy configuration (§7.12.3).
|
|
303
|
+
|
|
304
|
+
Defines how long different classifications of data are retained
|
|
305
|
+
and what happens at expiry.
|
|
306
|
+
"""
|
|
307
|
+
|
|
308
|
+
default_retention_hours: float = 720.0 # 30 days
|
|
309
|
+
restricted_retention_hours: float = 24.0 # 24 hours
|
|
310
|
+
critical_retention_hours: float = 1.0 # 1 hour
|
|
311
|
+
auto_purge: bool = True # Automatically delete expired data
|
|
312
|
+
purge_method: str = "zero_fill" # "zero_fill" or "delete"
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@dataclass
|
|
316
|
+
class RetentionRecord:
|
|
317
|
+
"""Tracks retention status for a piece of data."""
|
|
318
|
+
|
|
319
|
+
data_id: str
|
|
320
|
+
classification: DataClassification
|
|
321
|
+
created_at: float
|
|
322
|
+
expires_at: float
|
|
323
|
+
source_label: str = ""
|
|
324
|
+
purged: bool = False
|
|
325
|
+
purged_at: float = 0.0
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
class RetentionManager:
|
|
329
|
+
"""Manages data retention and automatic purging (§7.12.3).
|
|
330
|
+
|
|
331
|
+
EU AI Act Art. 12: Record-keeping with defined retention periods.
|
|
332
|
+
ISO 42001 A.6.2.8: Records management with lifecycle tracking.
|
|
333
|
+
|
|
334
|
+
Usage::
|
|
335
|
+
|
|
336
|
+
rm = RetentionManager()
|
|
337
|
+
rm.register("fact-123", DataClassification.RESTRICTED, "user-input")
|
|
338
|
+
expired = rm.get_expired() # Returns IDs ready for purging
|
|
339
|
+
rm.mark_purged("fact-123")
|
|
340
|
+
"""
|
|
341
|
+
|
|
342
|
+
def __init__(self, policy: RetentionPolicy | None = None) -> None:
|
|
343
|
+
self._policy = policy or RetentionPolicy()
|
|
344
|
+
self._records: dict[str, RetentionRecord] = {}
|
|
345
|
+
|
|
346
|
+
@property
|
|
347
|
+
def tracked_count(self) -> int:
|
|
348
|
+
return len(self._records)
|
|
349
|
+
|
|
350
|
+
@property
|
|
351
|
+
def active_count(self) -> int:
|
|
352
|
+
return sum(1 for r in self._records.values() if not r.purged)
|
|
353
|
+
|
|
354
|
+
def _retention_hours(self, classification: DataClassification) -> float:
|
|
355
|
+
"""Get retention hours for a classification level."""
|
|
356
|
+
req = _CLASSIFICATION_REQUIREMENTS.get(classification, {})
|
|
357
|
+
max_hours = req.get("max_retention_hours", 0)
|
|
358
|
+
if max_hours > 0:
|
|
359
|
+
return float(max_hours)
|
|
360
|
+
return self._policy.default_retention_hours
|
|
361
|
+
|
|
362
|
+
def register(
|
|
363
|
+
self,
|
|
364
|
+
data_id: str,
|
|
365
|
+
classification: DataClassification,
|
|
366
|
+
source_label: str = "",
|
|
367
|
+
) -> RetentionRecord:
|
|
368
|
+
"""Register a data item for retention tracking."""
|
|
369
|
+
now = time.time()
|
|
370
|
+
hours = self._retention_hours(classification)
|
|
371
|
+
expires_at = now + (hours * 3600) if hours > 0 else 0.0
|
|
372
|
+
|
|
373
|
+
record = RetentionRecord(
|
|
374
|
+
data_id=data_id,
|
|
375
|
+
classification=classification,
|
|
376
|
+
created_at=now,
|
|
377
|
+
expires_at=expires_at,
|
|
378
|
+
source_label=source_label,
|
|
379
|
+
)
|
|
380
|
+
self._records[data_id] = record
|
|
381
|
+
return record
|
|
382
|
+
|
|
383
|
+
def get_expired(self) -> list[str]:
|
|
384
|
+
"""Return IDs of all expired, non-purged data items."""
|
|
385
|
+
now = time.time()
|
|
386
|
+
return [
|
|
387
|
+
rid
|
|
388
|
+
for rid, rec in self._records.items()
|
|
389
|
+
if not rec.purged and rec.expires_at > 0 and now > rec.expires_at
|
|
390
|
+
]
|
|
391
|
+
|
|
392
|
+
def mark_purged(self, data_id: str) -> bool:
|
|
393
|
+
"""Mark a data item as purged. Returns True if found."""
|
|
394
|
+
rec = self._records.get(data_id)
|
|
395
|
+
if rec is None:
|
|
396
|
+
return False
|
|
397
|
+
rec.purged = True
|
|
398
|
+
rec.purged_at = time.time()
|
|
399
|
+
return True
|
|
400
|
+
|
|
401
|
+
def enforce(self) -> list[str]:
|
|
402
|
+
"""Run retention enforcement: return IDs of newly expired items.
|
|
403
|
+
|
|
404
|
+
The caller is responsible for actually deleting the data.
|
|
405
|
+
This method only identifies what needs purging.
|
|
406
|
+
"""
|
|
407
|
+
expired = self.get_expired()
|
|
408
|
+
if expired:
|
|
409
|
+
logger.info(
|
|
410
|
+
"Retention enforcement: %d items expired and pending purge",
|
|
411
|
+
len(expired),
|
|
412
|
+
)
|
|
413
|
+
return expired
|
|
414
|
+
|
|
415
|
+
def get_record(self, data_id: str) -> RetentionRecord | None:
|
|
416
|
+
"""Get retention record for a data item."""
|
|
417
|
+
return self._records.get(data_id)
|
|
418
|
+
|
|
419
|
+
def to_dict(self) -> dict[str, Any]:
|
|
420
|
+
"""Export retention state for audit/compliance reporting."""
|
|
421
|
+
return {
|
|
422
|
+
"tracked_count": self.tracked_count,
|
|
423
|
+
"active_count": self.active_count,
|
|
424
|
+
"purged_count": self.tracked_count - self.active_count,
|
|
425
|
+
"policy": {
|
|
426
|
+
"default_retention_hours": self._policy.default_retention_hours,
|
|
427
|
+
"restricted_retention_hours": self._policy.restricted_retention_hours,
|
|
428
|
+
"critical_retention_hours": self._policy.critical_retention_hours,
|
|
429
|
+
"auto_purge": self._policy.auto_purge,
|
|
430
|
+
"purge_method": self._policy.purge_method,
|
|
431
|
+
},
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
# ---------------------------------------------------------------------------
|
|
436
|
+
# Right to erasure (GDPR Article 17)
|
|
437
|
+
# ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@dataclass
|
|
441
|
+
class ErasureRequest:
|
|
442
|
+
"""Tracks a right-to-erasure (data deletion) request."""
|
|
443
|
+
|
|
444
|
+
request_id: str
|
|
445
|
+
requester_hash: str # SHA-256 hash of requester identifier
|
|
446
|
+
requested_at: float = field(default_factory=time.time)
|
|
447
|
+
scope: str = "session" # "session" | "all" | "specific_facts"
|
|
448
|
+
target_ids: list[str] = field(default_factory=list)
|
|
449
|
+
completed: bool = False
|
|
450
|
+
completed_at: float = 0.0
|
|
451
|
+
items_erased: int = 0
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
class ErasureManager:
|
|
455
|
+
"""Handles right-to-erasure requests (GDPR Article 17) (§7.12.4).
|
|
456
|
+
|
|
457
|
+
Tracks erasure requests, ensures they are fulfilled, and maintains
|
|
458
|
+
an audit trail of what was deleted and when.
|
|
459
|
+
|
|
460
|
+
Usage::
|
|
461
|
+
|
|
462
|
+
em = ErasureManager()
|
|
463
|
+
req = em.create_request("user-hash-abc", scope="session")
|
|
464
|
+
# ... caller erases the actual data ...
|
|
465
|
+
em.complete_request(req.request_id, items_erased=42)
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
def __init__(self) -> None:
|
|
469
|
+
self._requests: dict[str, ErasureRequest] = {}
|
|
470
|
+
|
|
471
|
+
def create_request(
|
|
472
|
+
self,
|
|
473
|
+
requester_hash: str,
|
|
474
|
+
scope: str = "session",
|
|
475
|
+
target_ids: list[str] | None = None,
|
|
476
|
+
) -> ErasureRequest:
|
|
477
|
+
"""Create a new erasure request.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
requester_hash: SHA-256 hash of the requester's identity.
|
|
481
|
+
scope: "session" (all data in current session),
|
|
482
|
+
"all" (all data across sessions),
|
|
483
|
+
"specific_facts" (only listed fact IDs).
|
|
484
|
+
target_ids: Specific fact IDs when scope="specific_facts".
|
|
485
|
+
|
|
486
|
+
Returns:
|
|
487
|
+
ErasureRequest with a unique request_id.
|
|
488
|
+
"""
|
|
489
|
+
import uuid
|
|
490
|
+
|
|
491
|
+
request_id = f"erasure-{uuid.uuid4().hex[:12]}"
|
|
492
|
+
req = ErasureRequest(
|
|
493
|
+
request_id=request_id,
|
|
494
|
+
requester_hash=requester_hash,
|
|
495
|
+
scope=scope,
|
|
496
|
+
target_ids=target_ids or [],
|
|
497
|
+
)
|
|
498
|
+
self._requests[request_id] = req
|
|
499
|
+
|
|
500
|
+
logger.info(
|
|
501
|
+
"Erasure request created: %s (scope=%s, targets=%d)",
|
|
502
|
+
request_id,
|
|
503
|
+
scope,
|
|
504
|
+
len(req.target_ids),
|
|
505
|
+
)
|
|
506
|
+
return req
|
|
507
|
+
|
|
508
|
+
def complete_request(
|
|
509
|
+
self,
|
|
510
|
+
request_id: str,
|
|
511
|
+
items_erased: int = 0,
|
|
512
|
+
) -> bool:
|
|
513
|
+
"""Mark an erasure request as completed.
|
|
514
|
+
|
|
515
|
+
Returns True if request was found and completed.
|
|
516
|
+
"""
|
|
517
|
+
req = self._requests.get(request_id)
|
|
518
|
+
if req is None:
|
|
519
|
+
return False
|
|
520
|
+
req.completed = True
|
|
521
|
+
req.completed_at = time.time()
|
|
522
|
+
req.items_erased = items_erased
|
|
523
|
+
|
|
524
|
+
logger.info(
|
|
525
|
+
"Erasure request completed: %s (%d items erased in %.1fs)",
|
|
526
|
+
request_id,
|
|
527
|
+
items_erased,
|
|
528
|
+
req.completed_at - req.requested_at,
|
|
529
|
+
)
|
|
530
|
+
return True
|
|
531
|
+
|
|
532
|
+
def pending_requests(self) -> list[ErasureRequest]:
|
|
533
|
+
"""Return all pending (incomplete) erasure requests."""
|
|
534
|
+
return [r for r in self._requests.values() if not r.completed]
|
|
535
|
+
|
|
536
|
+
def to_dict(self) -> dict[str, Any]:
|
|
537
|
+
"""Export erasure state for audit/compliance reporting."""
|
|
538
|
+
return {
|
|
539
|
+
"total_requests": len(self._requests),
|
|
540
|
+
"completed": sum(1 for r in self._requests.values() if r.completed),
|
|
541
|
+
"pending": sum(1 for r in self._requests.values() if not r.completed),
|
|
542
|
+
"total_items_erased": sum(
|
|
543
|
+
r.items_erased for r in self._requests.values() if r.completed
|
|
544
|
+
),
|
|
545
|
+
"requests": [
|
|
546
|
+
{
|
|
547
|
+
"request_id": r.request_id,
|
|
548
|
+
"scope": r.scope,
|
|
549
|
+
"completed": r.completed,
|
|
550
|
+
"items_erased": r.items_erased,
|
|
551
|
+
"requested_at": r.requested_at,
|
|
552
|
+
"completed_at": r.completed_at if r.completed else None,
|
|
553
|
+
}
|
|
554
|
+
for r in self._requests.values()
|
|
555
|
+
],
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
# ---------------------------------------------------------------------------
|
|
560
|
+
# Data lineage tracking
|
|
561
|
+
# ---------------------------------------------------------------------------
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
@dataclass
|
|
565
|
+
class DataLineageEntry:
|
|
566
|
+
"""Tracks the origin and transformations of a piece of data."""
|
|
567
|
+
|
|
568
|
+
data_id: str
|
|
569
|
+
origin: str # "ingest", "extraction", "dispatch_output", "share"
|
|
570
|
+
source_label: str = ""
|
|
571
|
+
classification: DataClassification = DataClassification.INTERNAL
|
|
572
|
+
created_at: float = field(default_factory=time.time)
|
|
573
|
+
transformations: list[str] = field(default_factory=list)
|
|
574
|
+
parent_ids: list[str] = field(default_factory=list)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
class DataLineageTracker:
|
|
578
|
+
"""Track data provenance and transformation history (§7.12.5).
|
|
579
|
+
|
|
580
|
+
ISO 42001 A.6.2.6: Data management requires tracking data origin,
|
|
581
|
+
quality, and transformations throughout the AI lifecycle.
|
|
582
|
+
|
|
583
|
+
Usage::
|
|
584
|
+
|
|
585
|
+
tracker = DataLineageTracker()
|
|
586
|
+
tracker.record("fact-1", "extraction", "user-input", DataClassification.INTERNAL)
|
|
587
|
+
tracker.add_transformation("fact-1", "quarantine_promoted")
|
|
588
|
+
"""
|
|
589
|
+
|
|
590
|
+
def __init__(self) -> None:
|
|
591
|
+
self._entries: dict[str, DataLineageEntry] = {}
|
|
592
|
+
|
|
593
|
+
def record(
|
|
594
|
+
self,
|
|
595
|
+
data_id: str,
|
|
596
|
+
origin: str,
|
|
597
|
+
source_label: str = "",
|
|
598
|
+
classification: DataClassification = DataClassification.INTERNAL,
|
|
599
|
+
parent_ids: list[str] | None = None,
|
|
600
|
+
) -> DataLineageEntry:
|
|
601
|
+
"""Record a new data lineage entry."""
|
|
602
|
+
entry = DataLineageEntry(
|
|
603
|
+
data_id=data_id,
|
|
604
|
+
origin=origin,
|
|
605
|
+
source_label=source_label,
|
|
606
|
+
classification=classification,
|
|
607
|
+
parent_ids=parent_ids or [],
|
|
608
|
+
)
|
|
609
|
+
self._entries[data_id] = entry
|
|
610
|
+
return entry
|
|
611
|
+
|
|
612
|
+
def add_transformation(self, data_id: str, transformation: str) -> bool:
|
|
613
|
+
"""Record a transformation applied to data. Returns True if found."""
|
|
614
|
+
entry = self._entries.get(data_id)
|
|
615
|
+
if entry is None:
|
|
616
|
+
return False
|
|
617
|
+
entry.transformations.append(transformation)
|
|
618
|
+
return True
|
|
619
|
+
|
|
620
|
+
def reclassify(
|
|
621
|
+
self, data_id: str, new_classification: DataClassification
|
|
622
|
+
) -> bool:
|
|
623
|
+
"""Update classification level for a data item. Returns True if found."""
|
|
624
|
+
entry = self._entries.get(data_id)
|
|
625
|
+
if entry is None:
|
|
626
|
+
return False
|
|
627
|
+
old = entry.classification
|
|
628
|
+
entry.classification = new_classification
|
|
629
|
+
entry.transformations.append(
|
|
630
|
+
f"reclassified:{old.name}->{new_classification.name}"
|
|
631
|
+
)
|
|
632
|
+
return True
|
|
633
|
+
|
|
634
|
+
def get_lineage(self, data_id: str) -> DataLineageEntry | None:
|
|
635
|
+
"""Get lineage entry for a data item."""
|
|
636
|
+
return self._entries.get(data_id)
|
|
637
|
+
|
|
638
|
+
def get_by_classification(
|
|
639
|
+
self, level: DataClassification
|
|
640
|
+
) -> list[DataLineageEntry]:
|
|
641
|
+
"""Get all entries at or above a classification level."""
|
|
642
|
+
return [e for e in self._entries.values() if e.classification >= level]
|
|
643
|
+
|
|
644
|
+
def to_dict(self) -> dict[str, Any]:
|
|
645
|
+
"""Export lineage state for audit/compliance reporting."""
|
|
646
|
+
by_class = {}
|
|
647
|
+
for entry in self._entries.values():
|
|
648
|
+
name = entry.classification.name
|
|
649
|
+
by_class[name] = by_class.get(name, 0) + 1
|
|
650
|
+
|
|
651
|
+
return {
|
|
652
|
+
"total_tracked": len(self._entries),
|
|
653
|
+
"by_classification": by_class,
|
|
654
|
+
"by_origin": self._count_by_field("origin"),
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
def _count_by_field(self, field_name: str) -> dict[str, int]:
|
|
658
|
+
counts: dict[str, int] = {}
|
|
659
|
+
for entry in self._entries.values():
|
|
660
|
+
val = getattr(entry, field_name, "unknown")
|
|
661
|
+
counts[val] = counts.get(val, 0) + 1
|
|
662
|
+
return counts
|