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
|
@@ -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)
|