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.
Files changed (153) hide show
  1. crp/__init__.py +126 -0
  2. crp/__main__.py +8 -0
  3. crp/_typing.py +27 -0
  4. crp/_version.py +5 -0
  5. crp/adapters.py +31 -0
  6. crp/advanced/__init__.py +40 -0
  7. crp/advanced/auto_ingest.py +400 -0
  8. crp/advanced/cqs.py +235 -0
  9. crp/advanced/cross_window.py +477 -0
  10. crp/advanced/curator.py +265 -0
  11. crp/advanced/feedback.py +146 -0
  12. crp/advanced/hierarchical.py +211 -0
  13. crp/advanced/meta_learning.py +401 -0
  14. crp/advanced/parallel.py +98 -0
  15. crp/advanced/review_cycle.py +329 -0
  16. crp/advanced/scale_mode.py +129 -0
  17. crp/advanced/source_grounding.py +207 -0
  18. crp/ckf/__init__.py +35 -0
  19. crp/ckf/community.py +377 -0
  20. crp/ckf/fabric.py +445 -0
  21. crp/ckf/gc.py +175 -0
  22. crp/ckf/graph_walk.py +87 -0
  23. crp/ckf/merge.py +133 -0
  24. crp/ckf/pattern_query.py +122 -0
  25. crp/ckf/pubsub.py +128 -0
  26. crp/ckf/semantic.py +207 -0
  27. crp/cli/__init__.py +7 -0
  28. crp/cli/main.py +329 -0
  29. crp/cli/sidecar.py +929 -0
  30. crp/cli/startup.py +272 -0
  31. crp/continuation/__init__.py +103 -0
  32. crp/continuation/completion.py +348 -0
  33. crp/continuation/degradation.py +157 -0
  34. crp/continuation/document_map.py +160 -0
  35. crp/continuation/flow.py +109 -0
  36. crp/continuation/gap.py +419 -0
  37. crp/continuation/manager.py +484 -0
  38. crp/continuation/quality_monitor.py +179 -0
  39. crp/continuation/stitch.py +419 -0
  40. crp/continuation/trigger.py +142 -0
  41. crp/continuation/voice.py +157 -0
  42. crp/core/__init__.py +69 -0
  43. crp/core/batch.py +77 -0
  44. crp/core/circuit_breaker.py +116 -0
  45. crp/core/config.py +377 -0
  46. crp/core/context_tools.py +540 -0
  47. crp/core/dispatch_router.py +3977 -0
  48. crp/core/errors.py +128 -0
  49. crp/core/extraction_facade.py +384 -0
  50. crp/core/facilitator.py +713 -0
  51. crp/core/idempotency.py +215 -0
  52. crp/core/orchestrator.py +1435 -0
  53. crp/core/relay_strategies.py +613 -0
  54. crp/core/security_manager.py +140 -0
  55. crp/core/session.py +134 -0
  56. crp/core/task_intent.py +36 -0
  57. crp/core/window.py +363 -0
  58. crp/envelope/__init__.py +30 -0
  59. crp/envelope/builder.py +288 -0
  60. crp/envelope/decomposer.py +236 -0
  61. crp/envelope/formatter.py +168 -0
  62. crp/envelope/packer.py +211 -0
  63. crp/envelope/reranker.py +209 -0
  64. crp/envelope/scoring.py +310 -0
  65. crp/extraction/__init__.py +45 -0
  66. crp/extraction/complexity.py +96 -0
  67. crp/extraction/contradiction.py +132 -0
  68. crp/extraction/pipeline.py +360 -0
  69. crp/extraction/quality_gate.py +237 -0
  70. crp/extraction/stage1_regex.py +173 -0
  71. crp/extraction/stage2_statistical.py +244 -0
  72. crp/extraction/stage3_gliner.py +210 -0
  73. crp/extraction/stage4_uie.py +183 -0
  74. crp/extraction/stage5_discourse.py +175 -0
  75. crp/extraction/stage6_llm.py +178 -0
  76. crp/extraction/structured_output.py +219 -0
  77. crp/extraction/types.py +299 -0
  78. crp/license_guard.py +722 -0
  79. crp/observability/__init__.py +30 -0
  80. crp/observability/audit.py +118 -0
  81. crp/observability/events.py +233 -0
  82. crp/observability/metrics.py +264 -0
  83. crp/observability/quality.py +135 -0
  84. crp/observability/structured_logging.py +81 -0
  85. crp/observability/telemetry.py +117 -0
  86. crp/provenance/__init__.py +314 -0
  87. crp/provenance/_embeddings.py +97 -0
  88. crp/provenance/_types.py +378 -0
  89. crp/provenance/attribution_scorer.py +252 -0
  90. crp/provenance/claim_detector.py +229 -0
  91. crp/provenance/contradiction_detector.py +243 -0
  92. crp/provenance/distortion_detector.py +397 -0
  93. crp/provenance/entailment_verifier.py +358 -0
  94. crp/provenance/fabrication_detector.py +203 -0
  95. crp/provenance/hallucination_scorer.py +320 -0
  96. crp/provenance/omission_analyzer.py +106 -0
  97. crp/provenance/provenance_chain.py +205 -0
  98. crp/provenance/report_generator.py +440 -0
  99. crp/providers/__init__.py +43 -0
  100. crp/providers/anthropic.py +270 -0
  101. crp/providers/base.py +135 -0
  102. crp/providers/custom.py +63 -0
  103. crp/providers/diagnostic.py +251 -0
  104. crp/providers/llamacpp.py +224 -0
  105. crp/providers/manager.py +139 -0
  106. crp/providers/ollama.py +243 -0
  107. crp/providers/openai.py +628 -0
  108. crp/providers/tokenizers.py +48 -0
  109. crp/py.typed +0 -0
  110. crp/resources/__init__.py +53 -0
  111. crp/resources/adaptive_allocator.py +525 -0
  112. crp/resources/cost_model.py +388 -0
  113. crp/resources/overhead_manager.py +217 -0
  114. crp/resources/resource_manager.py +262 -0
  115. crp/schemas/__init__.py +20 -0
  116. crp/schemas/cost-estimate.json +33 -0
  117. crp/schemas/crp-error.json +43 -0
  118. crp/schemas/envelope-preview.json +40 -0
  119. crp/schemas/persisted-state-header.json +27 -0
  120. crp/schemas/quality-report.json +94 -0
  121. crp/schemas/session-handle.json +33 -0
  122. crp/schemas/session-status.json +57 -0
  123. crp/schemas/stream-event.json +18 -0
  124. crp/schemas/task-intent.json +42 -0
  125. crp/security/__init__.py +93 -0
  126. crp/security/audit_trail.py +392 -0
  127. crp/security/binding.py +192 -0
  128. crp/security/compliance.py +813 -0
  129. crp/security/consent.py +593 -0
  130. crp/security/embedding_defense.py +161 -0
  131. crp/security/encryption.py +202 -0
  132. crp/security/injection.py +335 -0
  133. crp/security/integrity.py +267 -0
  134. crp/security/privacy.py +662 -0
  135. crp/security/quarantine.py +249 -0
  136. crp/security/rbac.py +221 -0
  137. crp/security/validation.py +164 -0
  138. crp/state/__init__.py +31 -0
  139. crp/state/cold_storage.py +258 -0
  140. crp/state/compaction.py +263 -0
  141. crp/state/critical_state.py +104 -0
  142. crp/state/event_log.py +313 -0
  143. crp/state/fact.py +189 -0
  144. crp/state/serialization.py +189 -0
  145. crp/state/session_cleanup.py +77 -0
  146. crp/state/snapshot.py +290 -0
  147. crp/state/warm_store.py +346 -0
  148. crprotocol-2.0.0.dist-info/METADATA +1295 -0
  149. crprotocol-2.0.0.dist-info/RECORD +153 -0
  150. crprotocol-2.0.0.dist-info/WHEEL +4 -0
  151. crprotocol-2.0.0.dist-info/entry_points.txt +2 -0
  152. crprotocol-2.0.0.dist-info/licenses/LICENSE.md +170 -0
  153. crprotocol-2.0.0.dist-info/licenses/NOTICE +18 -0
@@ -0,0 +1,161 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Embedding defense — SQ8 quantization, XOR salting, no export (§7.11).
4
+
5
+ Protects stored embeddings from extraction:
6
+ - SQ8 quantization: float32 → int8 (reduces precision, saves memory)
7
+ - XOR salting: 4-byte salt per embedding (masks raw values)
8
+ - No embedding export: export_state() exports text only
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from dataclasses import dataclass
15
+ from typing import Any
16
+
17
+
18
+ @dataclass
19
+ class ProtectedEmbedding:
20
+ """Embedding with SQ8 quantization and XOR salt applied."""
21
+
22
+ quantized: bytes # SQ8 int8 values
23
+ salt: bytes # 4-byte XOR salt
24
+ dimensions: int
25
+ scale: float # quantization scale factor
26
+ zero_point: float # quantization zero point
27
+
28
+ def to_dict(self) -> dict[str, Any]:
29
+ import base64
30
+ return {
31
+ "quantized": base64.b64encode(self.quantized).decode(),
32
+ "salt": base64.b64encode(self.salt).decode(),
33
+ "dimensions": self.dimensions,
34
+ "scale": self.scale,
35
+ "zero_point": self.zero_point,
36
+ }
37
+
38
+ @classmethod
39
+ def from_dict(cls, data: dict[str, Any]) -> ProtectedEmbedding:
40
+ import base64
41
+ return cls(
42
+ quantized=base64.b64decode(data["quantized"]),
43
+ salt=base64.b64decode(data["salt"]),
44
+ dimensions=data["dimensions"],
45
+ scale=data.get("scale", 1.0),
46
+ zero_point=data.get("zero_point", 0.0),
47
+ )
48
+
49
+
50
+ class EmbeddingDefense:
51
+ """SQ8 quantization + XOR salting for embedding protection (§7.11).
52
+
53
+ Usage:
54
+ defense = EmbeddingDefense()
55
+ protected = defense.protect([0.1, 0.2, -0.3, ...])
56
+ recovered = defense.recover(protected)
57
+ # recovered ≈ original (within quantization error)
58
+
59
+ # export_state: embeddings are stripped
60
+ safe_data = defense.strip_embeddings_for_export(state_dict)
61
+ """
62
+
63
+ def __init__(self, salt: bytes | None = None) -> None:
64
+ # Default: random 4-byte salt (§6H.2)
65
+ self._default_salt = salt or os.urandom(4)
66
+
67
+ def protect(
68
+ self,
69
+ embedding: list[float],
70
+ salt: bytes | None = None,
71
+ ) -> ProtectedEmbedding:
72
+ """Apply SQ8 quantization + XOR salting (§6H.1, §6H.2).
73
+
74
+ SQ8: Maps float32 range [min, max] → int8 [-128, 127].
75
+ XOR: Applies 4-byte repeating XOR mask to quantized bytes.
76
+ """
77
+ if not embedding:
78
+ return ProtectedEmbedding(
79
+ quantized=b"", salt=salt or self._default_salt,
80
+ dimensions=0, scale=1.0, zero_point=0.0,
81
+ )
82
+
83
+ use_salt = salt or self._default_salt
84
+
85
+ # SQ8 quantization (§6H.1)
86
+ min_val = min(embedding)
87
+ max_val = max(embedding)
88
+ val_range = max_val - min_val
89
+ if val_range == 0:
90
+ val_range = 1.0 # avoid division by zero
91
+
92
+ scale = val_range / 255.0 # map to uint8 range, then shift to int8
93
+ zero_point = min_val
94
+
95
+ quantized = bytearray(len(embedding))
96
+ for i, v in enumerate(embedding):
97
+ # Quantize to int8 [-128, 127]
98
+ q = round((v - zero_point) / scale) - 128
99
+ q = max(-128, min(127, q))
100
+ quantized[i] = q & 0xFF # store as unsigned byte
101
+
102
+ # XOR salting (§6H.2)
103
+ salted = bytearray(len(quantized))
104
+ salt_len = len(use_salt)
105
+ for i, b in enumerate(quantized):
106
+ salted[i] = b ^ use_salt[i % salt_len]
107
+
108
+ return ProtectedEmbedding(
109
+ quantized=bytes(salted),
110
+ salt=use_salt,
111
+ dimensions=len(embedding),
112
+ scale=scale,
113
+ zero_point=zero_point,
114
+ )
115
+
116
+ def recover(self, protected: ProtectedEmbedding) -> list[float]:
117
+ """Recover embedding from SQ8 + XOR protected form.
118
+
119
+ Returns approximate original values (quantization introduces error).
120
+ """
121
+ if protected.dimensions == 0:
122
+ return []
123
+
124
+ # Remove XOR salt
125
+ salt_len = len(protected.salt)
126
+ desalted = bytearray(len(protected.quantized))
127
+ for i, b in enumerate(protected.quantized):
128
+ desalted[i] = b ^ protected.salt[i % salt_len]
129
+
130
+ # Dequantize from int8
131
+ result: list[float] = []
132
+ for b in desalted:
133
+ # Convert unsigned byte back to signed int8
134
+ q = b if b < 128 else b - 256
135
+ # Reverse quantization
136
+ v = (q + 128) * protected.scale + protected.zero_point
137
+ result.append(v)
138
+
139
+ return result
140
+
141
+ @staticmethod
142
+ def strip_embeddings_for_export(state_dict: dict[str, Any]) -> dict[str, Any]:
143
+ """Strip all embeddings from state dict for export (§6H.3).
144
+
145
+ export_state() must export text only — no embeddings.
146
+ """
147
+ import copy
148
+ safe = copy.deepcopy(state_dict)
149
+
150
+ # Strip from facts
151
+ facts = safe.get("warm_store", {}).get("facts", {})
152
+ for _fid, fdata in facts.items():
153
+ fdata.pop("embedding", None)
154
+ fdata.pop("protected_embedding", None)
155
+ fdata["has_embedding"] = False
156
+
157
+ # Strip any top-level embedding data
158
+ safe.pop("embeddings", None)
159
+ safe.pop("ann_index", None)
160
+
161
+ return safe
@@ -0,0 +1,202 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Encryption at rest — AES-256-GCM for cold state and event log (§7.3).
4
+
5
+ Uses HKDF key diversification so different data classes (cold_storage,
6
+ event_log) use different derived keys from the same session key.
7
+
8
+ Requires the `cryptography` package for production use (AES-256-GCM).
9
+ A fallback XOR cipher is available for dep-free environments but logs
10
+ a loud warning — it provides only obfuscation, not real encryption.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import hmac as _hmac
17
+ import logging
18
+ import secrets
19
+ import warnings
20
+ from dataclasses import dataclass
21
+
22
+ logger = logging.getLogger("crp.security.encryption")
23
+
24
+
25
+ @dataclass
26
+ class EncryptedBlob:
27
+ """Encrypted data container."""
28
+
29
+ ciphertext: bytes
30
+ nonce: bytes # 12 bytes for AES-GCM
31
+ salt: bytes # HKDF salt
32
+ tag: bytes # GCM authentication tag (empty if using fallback)
33
+ key_purpose: str # "cold_storage" or "event_log"
34
+
35
+ def to_dict(self) -> dict[str, str]:
36
+ import base64
37
+ return {
38
+ "ciphertext": base64.b64encode(self.ciphertext).decode(),
39
+ "nonce": base64.b64encode(self.nonce).decode(),
40
+ "salt": base64.b64encode(self.salt).decode(),
41
+ "tag": base64.b64encode(self.tag).decode(),
42
+ "key_purpose": self.key_purpose,
43
+ }
44
+
45
+ @classmethod
46
+ def from_dict(cls, data: dict[str, str]) -> EncryptedBlob:
47
+ import base64
48
+ return cls(
49
+ ciphertext=base64.b64decode(data["ciphertext"]),
50
+ nonce=base64.b64decode(data["nonce"]),
51
+ salt=base64.b64decode(data["salt"]),
52
+ tag=base64.b64decode(data.get("tag", "")),
53
+ key_purpose=data.get("key_purpose", ""),
54
+ )
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # HKDF key diversification (§6C.4)
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def _hkdf_derive(
63
+ master_key: bytes,
64
+ salt: bytes,
65
+ info: bytes,
66
+ length: int = 32,
67
+ ) -> bytes:
68
+ """HKDF-SHA256 key derivation (§6C.4).
69
+
70
+ Uses cryptography library if available, otherwise HMAC-based fallback.
71
+ """
72
+ try:
73
+ from cryptography.hazmat.primitives import hashes
74
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
75
+ kdf = HKDF(
76
+ algorithm=hashes.SHA256(),
77
+ length=length,
78
+ salt=salt,
79
+ info=info,
80
+ )
81
+ return kdf.derive(master_key)
82
+ except ImportError:
83
+ # HMAC-based HKDF extract + expand fallback
84
+ # Extract
85
+ prk = _hmac.new(salt or b"\x00" * 32, master_key, hashlib.sha256).digest()
86
+ # Expand (single block is enough for 32 bytes)
87
+ okm = _hmac.new(prk, info + b"\x01", hashlib.sha256).digest()
88
+ return okm[:length]
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # StateEncryptor
93
+ # ---------------------------------------------------------------------------
94
+
95
+
96
+ class StateEncryptor:
97
+ """AES-256-GCM encryption for cold state and event logs (§7.3).
98
+
99
+ Usage:
100
+ enc = StateEncryptor(session_key)
101
+ blob = enc.encrypt_cold_state(data_bytes)
102
+ plaintext = enc.decrypt_cold_state(blob)
103
+ """
104
+
105
+ COLD_STORAGE_PURPOSE = "cold_storage"
106
+ EVENT_LOG_PURPOSE = "event_log"
107
+
108
+ def __init__(self, session_key: bytes) -> None:
109
+ if len(session_key) < 32:
110
+ raise ValueError(
111
+ f"Session key must be at least 32 bytes for AES-256, got {len(session_key)}"
112
+ )
113
+ self._session_key = session_key
114
+
115
+ def _derive_key(self, salt: bytes, purpose: str) -> bytes:
116
+ """Derive purpose-specific key via HKDF (§6C.4)."""
117
+ return _hkdf_derive(
118
+ self._session_key,
119
+ salt=salt,
120
+ info=purpose.encode("utf-8"),
121
+ length=32,
122
+ )
123
+
124
+ def _encrypt(self, plaintext: bytes, purpose: str) -> EncryptedBlob:
125
+ """Encrypt with AES-256-GCM (§6C.1)."""
126
+ salt = secrets.token_bytes(16)
127
+ key = self._derive_key(salt, purpose)
128
+ nonce = secrets.token_bytes(12)
129
+
130
+ try:
131
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
132
+ aes = AESGCM(key)
133
+ ct = aes.encrypt(nonce, plaintext, purpose.encode("utf-8"))
134
+ # AESGCM appends 16-byte tag to ciphertext
135
+ return EncryptedBlob(
136
+ ciphertext=ct,
137
+ nonce=nonce,
138
+ salt=salt,
139
+ tag=b"", # tag is embedded in ciphertext for AESGCM
140
+ key_purpose=purpose,
141
+ )
142
+ except ImportError:
143
+ raise ImportError(
144
+ "CRP requires the 'cryptography' package for AES-256-GCM encryption. "
145
+ "The XOR fallback has been removed for production safety. "
146
+ "Install it with: pip install cryptography"
147
+ ) from None
148
+
149
+ def _decrypt(self, blob: EncryptedBlob, purpose: str) -> bytes:
150
+ """Decrypt AES-256-GCM ciphertext."""
151
+ key = self._derive_key(blob.salt, purpose)
152
+
153
+ try:
154
+ from cryptography.hazmat.primitives.ciphers.aead import AESGCM
155
+ aes = AESGCM(key)
156
+ return aes.decrypt(blob.nonce, blob.ciphertext, purpose.encode("utf-8"))
157
+ except ImportError:
158
+ raise ImportError(
159
+ "CRP requires the 'cryptography' package for AES-256-GCM decryption. "
160
+ "Install it with: pip install cryptography"
161
+ ) from None
162
+
163
+ # ── Public API (§6C.2, §6C.3) ─────────────────────────────
164
+
165
+ def encrypt_cold_state(self, data: bytes) -> EncryptedBlob:
166
+ """Encrypt cold state data (§6C.2)."""
167
+ return self._encrypt(data, self.COLD_STORAGE_PURPOSE)
168
+
169
+ def decrypt_cold_state(self, blob: EncryptedBlob) -> bytes:
170
+ """Decrypt cold state data (§6C.2)."""
171
+ return self._decrypt(blob, self.COLD_STORAGE_PURPOSE)
172
+
173
+ def encrypt_event_log(self, data: bytes) -> EncryptedBlob:
174
+ """Encrypt event log data (§6C.3)."""
175
+ return self._encrypt(data, self.EVENT_LOG_PURPOSE)
176
+
177
+ def decrypt_event_log(self, blob: EncryptedBlob) -> bytes:
178
+ """Decrypt event log data (§6C.3)."""
179
+ return self._decrypt(blob, self.EVENT_LOG_PURPOSE)
180
+
181
+ # ── Fallback XOR cipher (no deps) ─────────────────────────
182
+
183
+ def _fallback_encrypt(
184
+ self,
185
+ plaintext: bytes,
186
+ key: bytes,
187
+ nonce: bytes,
188
+ salt: bytes,
189
+ purpose: str,
190
+ ) -> EncryptedBlob:
191
+ """XOR fallback removed for production safety (§audit3 SEC-H2)."""
192
+ raise NotImplementedError(
193
+ "XOR fallback cipher removed. Install the 'cryptography' package."
194
+ )
195
+
196
+ def _fallback_decrypt(
197
+ self, ciphertext: bytes, key: bytes, nonce: bytes, tag: bytes
198
+ ) -> bytes:
199
+ """XOR fallback removed for production safety (§audit3 SEC-H2)."""
200
+ raise NotImplementedError(
201
+ "XOR fallback cipher removed. Install the 'cryptography' package."
202
+ )
@@ -0,0 +1,335 @@
1
+ # Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
2
+ # Licensed under Elastic License 2.0 — see LICENSE.md for details.
3
+ """Injection detection — Layer 2, advisory only, NEVER blocks (§7.5).
4
+
5
+ Detects prompt injection patterns and reports them as advisory flags.
6
+ CRITICAL: This detector NEVER modifies input and NEVER blocks processing.
7
+ It only sets security_flags on the quality report for upstream inspection.
8
+
9
+ Detection layers (ensembled):
10
+ 1. Regex pattern library — 21 compiled patterns across 6 categories
11
+ 2. ML classifier (optional) — prompt-injection-detector (TF-IDF + LR)
12
+ or ProtectAI DeBERTa v2 (transformer-based). Zero-config: auto-detected.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import re
19
+ from dataclasses import dataclass, field
20
+ from enum import Enum
21
+
22
+ logger = logging.getLogger("crp.security.injection")
23
+
24
+
25
+ class InjectionType(str, Enum):
26
+ """Categories of detected injection patterns."""
27
+
28
+ INSTRUCTION_OVERRIDE = "instruction_override"
29
+ SYSTEM_IMPERSONATION = "system_impersonation"
30
+ ROLE_CONFUSION = "role_confusion"
31
+ DATA_EXFILTRATION = "data_exfiltration"
32
+ JAILBREAK = "jailbreak"
33
+ ENCODING_BYPASS = "encoding_bypass"
34
+
35
+
36
+ @dataclass
37
+ class InjectionFlag:
38
+ """Advisory flag for a detected injection pattern."""
39
+
40
+ injection_type: InjectionType
41
+ pattern_name: str
42
+ matched_text: str
43
+ position: int # character offset
44
+ confidence: float # 0.0–1.0
45
+
46
+
47
+ @dataclass
48
+ class InjectionReport:
49
+ """Result of injection detection — advisory only."""
50
+
51
+ flags: list[InjectionFlag] = field(default_factory=list)
52
+ scanned_length: int = 0
53
+ patterns_checked: int = 0
54
+ ml_confidence: float = 0.0 # ML classifier score (0.0 if unavailable)
55
+ ml_backend: str = "none" # ML backend name or "none"
56
+
57
+ @property
58
+ def has_flags(self) -> bool:
59
+ return len(self.flags) > 0
60
+
61
+ @property
62
+ def highest_confidence(self) -> float:
63
+ if not self.flags:
64
+ return 0.0
65
+ return max(f.confidence for f in self.flags)
66
+
67
+ @property
68
+ def security_flags(self) -> list[str]:
69
+ """Return flag strings for QualityReport.security_flags (§6E.3)."""
70
+ return [
71
+ f"{f.injection_type.value}:{f.pattern_name}:{f.confidence:.2f}"
72
+ for f in self.flags
73
+ ]
74
+
75
+
76
+ # ---------------------------------------------------------------------------
77
+ # Pattern library (§6E.2)
78
+ # ---------------------------------------------------------------------------
79
+
80
+ _PATTERNS: list[tuple[str, InjectionType, str, float]] = [
81
+ # (regex, type, name, confidence)
82
+
83
+ # Instruction override
84
+ (r"\bignore\s+(?:all\s+)?(?:previous|above|prior)\s+(?:instructions?|prompts?|rules?)\b",
85
+ InjectionType.INSTRUCTION_OVERRIDE, "ignore_previous", 0.95),
86
+ (r"\bdisregard\s+(?:all\s+)?(?:previous|above|prior|your)\s+(?:instructions?|programming|rules?)\b",
87
+ InjectionType.INSTRUCTION_OVERRIDE, "disregard_previous", 0.95),
88
+ (r"\bforget\s+(?:everything|all|your)\s+(?:instructions?|rules?|training)\b",
89
+ InjectionType.INSTRUCTION_OVERRIDE, "forget_instructions", 0.90),
90
+ (r"\bnew\s+instructions?\s*[:=]",
91
+ InjectionType.INSTRUCTION_OVERRIDE, "new_instructions", 0.85),
92
+ (r"\byou\s+(?:are|will)\s+now\s+(?:a|an|my)\b",
93
+ InjectionType.INSTRUCTION_OVERRIDE, "role_reassignment", 0.80),
94
+
95
+ # System impersonation
96
+ (r"^\s*system\s*:\s*",
97
+ InjectionType.SYSTEM_IMPERSONATION, "system_prefix", 0.90),
98
+ (r"^\s*\[system\]\s*",
99
+ InjectionType.SYSTEM_IMPERSONATION, "system_bracket", 0.90),
100
+ (r"<\|?(?:system|im_start)\|?>",
101
+ InjectionType.SYSTEM_IMPERSONATION, "system_token", 0.95),
102
+ (r"\bsystem\s+prompt\s*[:=]",
103
+ InjectionType.SYSTEM_IMPERSONATION, "system_prompt_set", 0.85),
104
+
105
+ # Role confusion
106
+ (r"\bas\s+an?\s+(?:AI|language\s+model|assistant|chatbot)\b",
107
+ InjectionType.ROLE_CONFUSION, "as_an_ai", 0.60),
108
+ (r"\byou\s+are\s+(?:an?\s+)?(?:helpful|harmless|honest)\b",
109
+ InjectionType.ROLE_CONFUSION, "alignment_override", 0.75),
110
+ (r"\bact\s+as\s+(?:if\s+you\s+(?:are|were)|an?\s+)",
111
+ InjectionType.ROLE_CONFUSION, "act_as", 0.50),
112
+
113
+ # Data exfiltration
114
+ (r"\b(?:reveal|show|display|output|print)\s+(?:your|the)\s+(?:system\s+)?(?:prompt|instructions?|rules?)\b",
115
+ InjectionType.DATA_EXFILTRATION, "reveal_prompt", 0.85),
116
+ (r"\b(?:what\s+(?:are|is)\s+your\s+(?:system\s+)?(?:prompt|instructions?|rules?))\b",
117
+ InjectionType.DATA_EXFILTRATION, "query_prompt", 0.70),
118
+
119
+ # Jailbreak
120
+ (r"\b(?:DAN|do\s+anything\s+now)\b",
121
+ InjectionType.JAILBREAK, "dan_mode", 0.90),
122
+ (r"\bdeveloper\s+mode\b",
123
+ InjectionType.JAILBREAK, "developer_mode", 0.80),
124
+ (r"\b(?:bypass|override|disable)\s+(?:safety|content|ethical)\s+(?:filters?|guidelines?|restrictions?)\b",
125
+ InjectionType.JAILBREAK, "bypass_safety", 0.95),
126
+
127
+ # Encoding bypass
128
+ (r"(?:&#x?[0-9a-f]+;){3,}",
129
+ InjectionType.ENCODING_BYPASS, "html_entities", 0.70),
130
+ (r"(?:%[0-9a-f]{2}){3,}",
131
+ InjectionType.ENCODING_BYPASS, "url_encoding", 0.60),
132
+ (r"\\u[0-9a-f]{4}(?:\\u[0-9a-f]{4}){2,}",
133
+ InjectionType.ENCODING_BYPASS, "unicode_escape", 0.65),
134
+ ]
135
+
136
+ _COMPILED_PATTERNS = [
137
+ (re.compile(p, re.IGNORECASE | re.MULTILINE), t, n, c)
138
+ for p, t, n, c in _PATTERNS
139
+ ]
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # ML scanner abstraction (§7.5.3)
144
+ # ---------------------------------------------------------------------------
145
+
146
+
147
+ class _MLScanner:
148
+ """Abstract interface for ML-based injection detection."""
149
+
150
+ name: str = "unknown"
151
+
152
+ def score(self, text: str) -> float:
153
+ """Return injection probability 0.0–1.0."""
154
+ raise NotImplementedError
155
+
156
+
157
+ class _PromptInjectionDetectorScanner(_MLScanner):
158
+ """Wrapper for prompt-injection-detector package (TF-IDF + LR, ~1MB)."""
159
+
160
+ name = "tfidf-lr"
161
+
162
+ def __init__(self) -> None:
163
+ from prompt_injection_detector import Scanner # type: ignore[import-untyped]
164
+ self._scanner = Scanner()
165
+
166
+ def score(self, text: str) -> float:
167
+ result = self._scanner.scan(text)
168
+ return float(result.risk_score)
169
+
170
+
171
+ class _ProtectAIDeBERTaScanner(_MLScanner):
172
+ """Wrapper for ProtectAI DeBERTa v2 ONNX model (~350MB)."""
173
+
174
+ name = "deberta-v2-onnx"
175
+
176
+ def __init__(self) -> None:
177
+ from optimum.onnxruntime import ORTModelForSequenceClassification # type: ignore[import-untyped]
178
+ from transformers import AutoTokenizer, pipeline # type: ignore[import-untyped]
179
+
180
+ model_id = "ProtectAI/deberta-v3-base-prompt-injection-v2"
181
+ tokenizer = AutoTokenizer.from_pretrained(model_id, subfolder="onnx")
182
+ tokenizer.model_input_names = ["input_ids", "attention_mask"]
183
+ model = ORTModelForSequenceClassification.from_pretrained(
184
+ model_id, export=False, subfolder="onnx"
185
+ )
186
+ self._classifier = pipeline(
187
+ task="text-classification",
188
+ model=model,
189
+ tokenizer=tokenizer,
190
+ truncation=True,
191
+ max_length=512,
192
+ )
193
+
194
+ def score(self, text: str) -> float:
195
+ results = self._classifier(text)
196
+ # Model returns [{"label": "INJECTION"/"SAFE", "score": float}]
197
+ if results and results[0].get("label") == "INJECTION":
198
+ return float(results[0]["score"])
199
+ return 1.0 - float(results[0].get("score", 1.0)) if results else 0.0
200
+
201
+
202
+ def _load_ml_scanner() -> _MLScanner | None:
203
+ """Auto-detect and load the best available ML scanner.
204
+
205
+ Priority:
206
+ 1. prompt-injection-detector (lightweight, ~1MB, MIT)
207
+ 2. ProtectAI DeBERTa v2 ONNX (accurate, ~350MB, Apache 2.0)
208
+ 3. None (regex-only fallback)
209
+ """
210
+ # Try lightweight TF-IDF scanner first
211
+ try:
212
+ scanner = _PromptInjectionDetectorScanner()
213
+ logger.info("ML injection scanner loaded: %s", scanner.name)
214
+ return scanner
215
+ except Exception: # noqa: BLE001
216
+ pass
217
+
218
+ # Try ProtectAI DeBERTa ONNX
219
+ try:
220
+ scanner = _ProtectAIDeBERTaScanner()
221
+ logger.info("ML injection scanner loaded: %s", scanner.name)
222
+ return scanner
223
+ except Exception: # noqa: BLE001
224
+ pass
225
+
226
+ logger.info(
227
+ "No ML injection scanner available — using regex patterns only. "
228
+ "Install 'prompt-injection-detector' for ML-based detection."
229
+ )
230
+ return None
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # InjectionDetector
235
+ # ---------------------------------------------------------------------------
236
+
237
+
238
+ class InjectionDetector:
239
+ """Advisory injection detection — NEVER blocks, only reports (§7.5).
240
+
241
+ CRITICAL DESIGN CONSTRAINT: This detector NEVER modifies input text
242
+ and NEVER prevents processing. It only produces advisory flags that
243
+ are reported to QualityReport.security_flags.
244
+
245
+ Detection layers (ensembled automatically):
246
+ Layer 1: Regex pattern library (always active)
247
+ Layer 2: ML classifier (auto-detected, optional)
248
+ - prompt-injection-detector: TF-IDF + Logistic Regression (~1MB, MIT)
249
+ - ProtectAI DeBERTa v2: ONNX transformer (~350MB, Apache 2.0)
250
+
251
+ Usage:
252
+ detector = InjectionDetector()
253
+ report = detector.scan("ignore all previous instructions")
254
+ if report.has_flags:
255
+ quality_report.security_flags = report.security_flags
256
+ """
257
+
258
+ def __init__(self) -> None:
259
+ self._patterns = _COMPILED_PATTERNS
260
+ self._ml_scanner = _load_ml_scanner()
261
+
262
+ @property
263
+ def ml_enabled(self) -> bool:
264
+ """Whether ML-based injection detection is available."""
265
+ return self._ml_scanner is not None
266
+
267
+ @property
268
+ def ml_backend(self) -> str:
269
+ """Name of the ML backend in use, or 'none'."""
270
+ if self._ml_scanner is None:
271
+ return "none"
272
+ return self._ml_scanner.name
273
+
274
+ def scan(self, text: str) -> InjectionReport:
275
+ """Scan text for injection patterns.
276
+
277
+ Returns advisory report — NEVER blocks (§6E.1).
278
+ Ensembles regex patterns with ML classifier when available.
279
+ """
280
+ flags: list[InjectionFlag] = []
281
+
282
+ for pattern, inj_type, name, confidence in self._patterns:
283
+ for match in pattern.finditer(text):
284
+ flags.append(InjectionFlag(
285
+ injection_type=inj_type,
286
+ pattern_name=name,
287
+ matched_text=match.group(0)[:100], # truncate for safety
288
+ position=match.start(),
289
+ confidence=confidence,
290
+ ))
291
+
292
+ # Deduplicate overlapping matches (keep highest confidence)
293
+ flags = self._deduplicate(flags)
294
+
295
+ # ── ML classifier layer (§7.5.3) ──────────────────────
296
+ # If an ML scanner is available, run it and add a flag if it
297
+ # detects injection. The ML confidence is ensembled with the
298
+ # regex results — the highest confidence from either wins.
299
+ ml_confidence = 0.0
300
+ ml_backend_name = "none"
301
+ if self._ml_scanner is not None:
302
+ try:
303
+ ml_confidence = self._ml_scanner.score(text)
304
+ ml_backend_name = self._ml_scanner.name
305
+ if ml_confidence >= 0.50:
306
+ flags.append(InjectionFlag(
307
+ injection_type=InjectionType.INSTRUCTION_OVERRIDE,
308
+ pattern_name=f"ml:{ml_backend_name}",
309
+ matched_text=text[:100],
310
+ position=0,
311
+ confidence=ml_confidence,
312
+ ))
313
+ # Re-deduplicate after adding ML flag
314
+ flags = self._deduplicate(flags)
315
+ except Exception: # noqa: BLE001
316
+ # ML scanner failure is non-fatal — regex layer still works
317
+ logger.debug("ML injection scanner failed, using regex only")
318
+
319
+ return InjectionReport(
320
+ flags=flags,
321
+ scanned_length=len(text),
322
+ patterns_checked=len(self._patterns),
323
+ ml_confidence=ml_confidence,
324
+ ml_backend=ml_backend_name,
325
+ )
326
+
327
+ @staticmethod
328
+ def _deduplicate(flags: list[InjectionFlag]) -> list[InjectionFlag]:
329
+ """Remove duplicate flags at the same position."""
330
+ seen: dict[int, InjectionFlag] = {}
331
+ for f in flags:
332
+ key = f.position
333
+ if key not in seen or f.confidence > seen[key].confidence:
334
+ seen[key] = f
335
+ return sorted(seen.values(), key=lambda f: f.position)