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,249 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Ingest quarantine — anti-poisoning with 1-window quarantine (§7.8).
|
|
4
|
+
|
|
5
|
+
Facts from untrusted sources are quarantined for 1 window with a 0.7×
|
|
6
|
+
confidence penalty. Cross-reference validation promotes or rejects them.
|
|
7
|
+
Batch poisoning detection: >30% failures → quarantine entire batch.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class QuarantineEntry:
|
|
18
|
+
"""A fact held in quarantine."""
|
|
19
|
+
|
|
20
|
+
fact_id: str
|
|
21
|
+
original_confidence: float
|
|
22
|
+
penalized_confidence: float # 0.7× original
|
|
23
|
+
quarantine_window_id: str
|
|
24
|
+
ingested_at: float = field(default_factory=time.time)
|
|
25
|
+
source_label: str = ""
|
|
26
|
+
promoted: bool = False
|
|
27
|
+
rejected: bool = False
|
|
28
|
+
rejection_reason: str = ""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class QuarantineReport:
|
|
33
|
+
"""Result of cross-reference validation pass."""
|
|
34
|
+
|
|
35
|
+
total_quarantined: int = 0
|
|
36
|
+
promoted: int = 0
|
|
37
|
+
rejected: int = 0
|
|
38
|
+
batch_poisoned: bool = False
|
|
39
|
+
details: list[str] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Confidence penalty factor for quarantined facts (§6F.1)
|
|
43
|
+
QUARANTINE_CONFIDENCE_FACTOR = 0.7
|
|
44
|
+
# Batch poisoning threshold: >30% failures → quarantine entire batch (§6F.3)
|
|
45
|
+
BATCH_POISON_THRESHOLD = 0.30
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class IngestQuarantine:
|
|
49
|
+
"""1-window quarantine with confidence penalty and batch poisoning detection (§7.8).
|
|
50
|
+
|
|
51
|
+
Workflow:
|
|
52
|
+
1. Incoming facts go into quarantine with 0.7× confidence
|
|
53
|
+
2. After 1 window, cross-reference against extraction-derived facts
|
|
54
|
+
3. Matching facts are promoted (confidence restored)
|
|
55
|
+
4. Non-matching facts are rejected
|
|
56
|
+
5. If >30% of a batch fails, quarantine entire batch
|
|
57
|
+
|
|
58
|
+
Usage:
|
|
59
|
+
q = IngestQuarantine()
|
|
60
|
+
q.quarantine_facts([...], "w-1", source="user_input")
|
|
61
|
+
# ... next window processes ...
|
|
62
|
+
report = q.validate_and_promote("w-2", extraction_fact_texts)
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(
|
|
66
|
+
self,
|
|
67
|
+
confidence_factor: float = QUARANTINE_CONFIDENCE_FACTOR,
|
|
68
|
+
batch_poison_threshold: float = BATCH_POISON_THRESHOLD,
|
|
69
|
+
) -> None:
|
|
70
|
+
self._factor = confidence_factor
|
|
71
|
+
self._batch_threshold = batch_poison_threshold
|
|
72
|
+
self._quarantined: dict[str, QuarantineEntry] = {}
|
|
73
|
+
self._window_batches: dict[str, list[str]] = {} # window_id → [fact_ids]
|
|
74
|
+
self._fact_texts: dict[str, str] = {} # fact_id → original text for similarity
|
|
75
|
+
self._promotion_history: list[QuarantineReport] = []
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def quarantine_count(self) -> int:
|
|
79
|
+
"""Number of facts currently in quarantine (not promoted or rejected)."""
|
|
80
|
+
return sum(
|
|
81
|
+
1 for e in self._quarantined.values()
|
|
82
|
+
if not e.promoted and not e.rejected
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def history(self) -> list[QuarantineReport]:
|
|
87
|
+
return list(self._promotion_history)
|
|
88
|
+
|
|
89
|
+
def quarantine_fact(
|
|
90
|
+
self,
|
|
91
|
+
fact_id: str,
|
|
92
|
+
original_confidence: float,
|
|
93
|
+
window_id: str,
|
|
94
|
+
source_label: str = "",
|
|
95
|
+
fact_text: str = "",
|
|
96
|
+
) -> QuarantineEntry:
|
|
97
|
+
"""Place a single fact into quarantine with 0.7× confidence penalty (§6F.1)."""
|
|
98
|
+
entry = QuarantineEntry(
|
|
99
|
+
fact_id=fact_id,
|
|
100
|
+
original_confidence=original_confidence,
|
|
101
|
+
penalized_confidence=original_confidence * self._factor,
|
|
102
|
+
quarantine_window_id=window_id,
|
|
103
|
+
source_label=source_label,
|
|
104
|
+
)
|
|
105
|
+
self._quarantined[fact_id] = entry
|
|
106
|
+
if fact_text:
|
|
107
|
+
self._fact_texts[fact_id] = fact_text
|
|
108
|
+
self._window_batches.setdefault(window_id, []).append(fact_id)
|
|
109
|
+
return entry
|
|
110
|
+
|
|
111
|
+
def quarantine_facts(
|
|
112
|
+
self,
|
|
113
|
+
facts: list[tuple[str, float]] | list[tuple[str, float, str]],
|
|
114
|
+
window_id: str,
|
|
115
|
+
source_label: str = "",
|
|
116
|
+
) -> list[QuarantineEntry]:
|
|
117
|
+
"""Quarantine a batch of facts.
|
|
118
|
+
|
|
119
|
+
Facts can be (fact_id, confidence) or (fact_id, confidence, text).
|
|
120
|
+
"""
|
|
121
|
+
entries = []
|
|
122
|
+
for item in facts:
|
|
123
|
+
if len(item) >= 3:
|
|
124
|
+
fid, conf, text = item[0], item[1], item[2]
|
|
125
|
+
else:
|
|
126
|
+
fid, conf, text = item[0], item[1], ""
|
|
127
|
+
entries.append(
|
|
128
|
+
self.quarantine_fact(fid, conf, window_id, source_label, text)
|
|
129
|
+
)
|
|
130
|
+
return entries
|
|
131
|
+
|
|
132
|
+
def get_penalized_confidence(self, fact_id: str) -> float | None:
|
|
133
|
+
"""Get quarantine-penalized confidence for a fact."""
|
|
134
|
+
entry = self._quarantined.get(fact_id)
|
|
135
|
+
if entry is None:
|
|
136
|
+
return None
|
|
137
|
+
if entry.promoted:
|
|
138
|
+
return entry.original_confidence
|
|
139
|
+
return entry.penalized_confidence
|
|
140
|
+
|
|
141
|
+
def is_quarantined(self, fact_id: str) -> bool:
|
|
142
|
+
"""Check if a fact is in active quarantine."""
|
|
143
|
+
entry = self._quarantined.get(fact_id)
|
|
144
|
+
if entry is None:
|
|
145
|
+
return False
|
|
146
|
+
return not entry.promoted and not entry.rejected
|
|
147
|
+
|
|
148
|
+
def validate_and_promote(
|
|
149
|
+
self,
|
|
150
|
+
current_window_id: str,
|
|
151
|
+
extraction_fact_texts: dict[str, str],
|
|
152
|
+
similarity_threshold: float = 0.5,
|
|
153
|
+
) -> QuarantineReport:
|
|
154
|
+
"""Cross-reference validation: promote or reject quarantined facts (§6F.2).
|
|
155
|
+
|
|
156
|
+
Facts quarantined in an earlier window are validated against
|
|
157
|
+
extraction-derived facts. Text overlap > threshold → promote.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
current_window_id: Current window being processed
|
|
161
|
+
extraction_fact_texts: {fact_id: text} from extraction pipeline
|
|
162
|
+
similarity_threshold: Word overlap threshold for cross-reference
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
QuarantineReport with promotion/rejection counts
|
|
166
|
+
"""
|
|
167
|
+
report = QuarantineReport()
|
|
168
|
+
extraction_words_sets = {
|
|
169
|
+
fid: set(text.lower().split())
|
|
170
|
+
for fid, text in extraction_fact_texts.items()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
# Check only facts quarantined in a PREVIOUS window
|
|
174
|
+
pending = [
|
|
175
|
+
(fid, entry) for fid, entry in self._quarantined.items()
|
|
176
|
+
if not entry.promoted and not entry.rejected
|
|
177
|
+
and entry.quarantine_window_id != current_window_id
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
report.total_quarantined = len(pending)
|
|
181
|
+
promoted_count = 0
|
|
182
|
+
rejected_count = 0
|
|
183
|
+
|
|
184
|
+
for fid, entry in pending:
|
|
185
|
+
# Cross-reference against extraction facts via word overlap
|
|
186
|
+
matched = False
|
|
187
|
+
# Look up the quarantined fact's text for similarity comparison
|
|
188
|
+
q_text = self._fact_texts.get(fid, "")
|
|
189
|
+
q_words = set(q_text.lower().split()) if q_text else set()
|
|
190
|
+
|
|
191
|
+
for ext_fid, ext_words in extraction_words_sets.items():
|
|
192
|
+
# Exact ID match — same fact re-extracted
|
|
193
|
+
if ext_fid == fid:
|
|
194
|
+
matched = True
|
|
195
|
+
break
|
|
196
|
+
# Word-overlap similarity: Jaccard-like measure
|
|
197
|
+
if q_words and ext_words:
|
|
198
|
+
overlap = len(q_words & ext_words)
|
|
199
|
+
union = len(q_words | ext_words)
|
|
200
|
+
if union > 0 and (overlap / union) >= similarity_threshold:
|
|
201
|
+
matched = True
|
|
202
|
+
break
|
|
203
|
+
|
|
204
|
+
if matched:
|
|
205
|
+
entry.promoted = True
|
|
206
|
+
promoted_count += 1
|
|
207
|
+
report.details.append(f"promoted:{fid}")
|
|
208
|
+
else:
|
|
209
|
+
entry.rejected = True
|
|
210
|
+
entry.rejection_reason = "no_cross_reference"
|
|
211
|
+
rejected_count += 1
|
|
212
|
+
report.details.append(f"rejected:{fid}")
|
|
213
|
+
|
|
214
|
+
report.promoted = promoted_count
|
|
215
|
+
report.rejected = rejected_count
|
|
216
|
+
|
|
217
|
+
# Batch poisoning detection (§6F.3)
|
|
218
|
+
if report.total_quarantined > 0:
|
|
219
|
+
reject_ratio = rejected_count / report.total_quarantined
|
|
220
|
+
if reject_ratio > self._batch_threshold:
|
|
221
|
+
report.batch_poisoned = True
|
|
222
|
+
# Mark all promoted facts as rejected too
|
|
223
|
+
for _fid, entry in pending:
|
|
224
|
+
if entry.promoted:
|
|
225
|
+
entry.promoted = False
|
|
226
|
+
entry.rejected = True
|
|
227
|
+
entry.rejection_reason = "batch_poisoning"
|
|
228
|
+
report.promoted -= 1
|
|
229
|
+
report.rejected += 1
|
|
230
|
+
report.details.append(
|
|
231
|
+
f"batch_poisoned: {reject_ratio:.0%} > {self._batch_threshold:.0%}"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
self._promotion_history.append(report)
|
|
235
|
+
return report
|
|
236
|
+
|
|
237
|
+
def get_active_entries(self) -> list[QuarantineEntry]:
|
|
238
|
+
"""Return all currently quarantined (non-promoted, non-rejected) entries."""
|
|
239
|
+
return [
|
|
240
|
+
e for e in self._quarantined.values()
|
|
241
|
+
if not e.promoted and not e.rejected
|
|
242
|
+
]
|
|
243
|
+
|
|
244
|
+
def clear(self) -> None:
|
|
245
|
+
"""Clear all quarantine state."""
|
|
246
|
+
self._quarantined.clear()
|
|
247
|
+
self._window_batches.clear()
|
|
248
|
+
self._fact_texts.clear()
|
|
249
|
+
self._promotion_history.clear()
|
crp/security/rbac.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""RBAC & rate limiting — role-based access control + per-session limits (§7.10).
|
|
4
|
+
|
|
5
|
+
Roles: OBSERVER (read-only), OPERATOR (dispatch + ingest), ADMIN (full).
|
|
6
|
+
Rate limits: 60 req/min dispatch, 100 MB/min ingest, per-session token cap.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import time
|
|
12
|
+
from collections import deque
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from enum import IntEnum
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Role(IntEnum):
|
|
18
|
+
"""RBAC roles with increasing privilege (§6G.1)."""
|
|
19
|
+
|
|
20
|
+
OBSERVER = 0 # Read-only: inspect state, read facts
|
|
21
|
+
OPERATOR = 1 # Dispatch + ingest: all observer + dispatch, ingest
|
|
22
|
+
ADMIN = 2 # Full: all operator + config, delete, export
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Permission(str):
|
|
26
|
+
"""Named permission strings."""
|
|
27
|
+
|
|
28
|
+
# Observer permissions
|
|
29
|
+
READ_FACTS = "read_facts"
|
|
30
|
+
READ_STATE = "read_state"
|
|
31
|
+
READ_ENVELOPE = "read_envelope"
|
|
32
|
+
READ_EVENTS = "read_events"
|
|
33
|
+
|
|
34
|
+
# Operator permissions
|
|
35
|
+
DISPATCH = "dispatch"
|
|
36
|
+
INGEST = "ingest"
|
|
37
|
+
STORE_FACTS = "store_facts"
|
|
38
|
+
|
|
39
|
+
# Admin permissions
|
|
40
|
+
DELETE_FACTS = "delete_facts"
|
|
41
|
+
EXPORT_STATE = "export_state"
|
|
42
|
+
CONFIGURE = "configure"
|
|
43
|
+
MANAGE_SESSIONS = "manage_sessions"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Role → allowed permissions
|
|
47
|
+
_ROLE_PERMISSIONS: dict[Role, frozenset[str]] = {
|
|
48
|
+
Role.OBSERVER: frozenset({
|
|
49
|
+
Permission.READ_FACTS,
|
|
50
|
+
Permission.READ_STATE,
|
|
51
|
+
Permission.READ_ENVELOPE,
|
|
52
|
+
Permission.READ_EVENTS,
|
|
53
|
+
}),
|
|
54
|
+
Role.OPERATOR: frozenset({
|
|
55
|
+
Permission.READ_FACTS,
|
|
56
|
+
Permission.READ_STATE,
|
|
57
|
+
Permission.READ_ENVELOPE,
|
|
58
|
+
Permission.READ_EVENTS,
|
|
59
|
+
Permission.DISPATCH,
|
|
60
|
+
Permission.INGEST,
|
|
61
|
+
Permission.STORE_FACTS,
|
|
62
|
+
Permission.EXPORT_STATE,
|
|
63
|
+
Permission.CONFIGURE,
|
|
64
|
+
}),
|
|
65
|
+
Role.ADMIN: frozenset({
|
|
66
|
+
Permission.READ_FACTS,
|
|
67
|
+
Permission.READ_STATE,
|
|
68
|
+
Permission.READ_ENVELOPE,
|
|
69
|
+
Permission.READ_EVENTS,
|
|
70
|
+
Permission.DISPATCH,
|
|
71
|
+
Permission.INGEST,
|
|
72
|
+
Permission.STORE_FACTS,
|
|
73
|
+
Permission.DELETE_FACTS,
|
|
74
|
+
Permission.EXPORT_STATE,
|
|
75
|
+
Permission.CONFIGURE,
|
|
76
|
+
Permission.MANAGE_SESSIONS,
|
|
77
|
+
}),
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class RateLimitConfig:
|
|
83
|
+
"""Rate limiting configuration (§6G.2)."""
|
|
84
|
+
|
|
85
|
+
dispatch_per_minute: int = 60
|
|
86
|
+
ingest_mb_per_minute: float = 100.0
|
|
87
|
+
session_token_cap: int = 0 # 0 = unlimited
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass
|
|
91
|
+
class RateLimitState:
|
|
92
|
+
"""Sliding-window rate limit tracking."""
|
|
93
|
+
|
|
94
|
+
dispatch_timestamps: deque[float] = field(default_factory=deque)
|
|
95
|
+
ingest_bytes: deque[tuple[float, int]] = field(default_factory=deque)
|
|
96
|
+
session_tokens_used: int = 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class AccessResult:
|
|
101
|
+
"""Result of an RBAC check."""
|
|
102
|
+
|
|
103
|
+
allowed: bool
|
|
104
|
+
reason: str = ""
|
|
105
|
+
role: Role = Role.OBSERVER
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class RBACEnforcer:
|
|
109
|
+
"""Role-based access control + rate limiting (§7.10).
|
|
110
|
+
|
|
111
|
+
Usage:
|
|
112
|
+
rbac = RBACEnforcer(role=Role.OPERATOR)
|
|
113
|
+
result = rbac.check_permission("dispatch")
|
|
114
|
+
if not result.allowed:
|
|
115
|
+
raise PermissionError(result.reason)
|
|
116
|
+
result = rbac.check_rate_limit("dispatch")
|
|
117
|
+
if not result.allowed:
|
|
118
|
+
raise RateLimitError(result.reason)
|
|
119
|
+
rbac.record_dispatch()
|
|
120
|
+
"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
role: Role = Role.OPERATOR,
|
|
125
|
+
config: RateLimitConfig | None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
self._role = role
|
|
128
|
+
self._config = config or RateLimitConfig()
|
|
129
|
+
self._limits = RateLimitState()
|
|
130
|
+
self._permissions = _ROLE_PERMISSIONS[role]
|
|
131
|
+
|
|
132
|
+
@property
|
|
133
|
+
def role(self) -> Role:
|
|
134
|
+
return self._role
|
|
135
|
+
|
|
136
|
+
@role.setter
|
|
137
|
+
def role(self, value: Role) -> None:
|
|
138
|
+
self._role = value
|
|
139
|
+
self._permissions = _ROLE_PERMISSIONS[value]
|
|
140
|
+
|
|
141
|
+
# ── Permission checks (§6G.1) ─────────────────────────────
|
|
142
|
+
|
|
143
|
+
def check_permission(self, permission: str) -> AccessResult:
|
|
144
|
+
"""Check if current role has the given permission."""
|
|
145
|
+
if permission in self._permissions:
|
|
146
|
+
return AccessResult(allowed=True, role=self._role)
|
|
147
|
+
return AccessResult(
|
|
148
|
+
allowed=False,
|
|
149
|
+
reason=f"Role {self._role.name} lacks permission '{permission}'",
|
|
150
|
+
role=self._role,
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
def has_permission(self, permission: str) -> bool:
|
|
154
|
+
return permission in self._permissions
|
|
155
|
+
|
|
156
|
+
# ── Rate limiting (§6G.2) ─────────────────────────────────
|
|
157
|
+
|
|
158
|
+
def check_rate_limit(self, operation: str, payload_bytes: int = 0) -> AccessResult:
|
|
159
|
+
"""Check if the operation is within rate limits."""
|
|
160
|
+
now = time.time()
|
|
161
|
+
window_start = now - 60.0 # 1-minute sliding window
|
|
162
|
+
|
|
163
|
+
if operation == Permission.DISPATCH:
|
|
164
|
+
# Clean old entries
|
|
165
|
+
while self._limits.dispatch_timestamps and self._limits.dispatch_timestamps[0] < window_start:
|
|
166
|
+
self._limits.dispatch_timestamps.popleft()
|
|
167
|
+
|
|
168
|
+
if len(self._limits.dispatch_timestamps) >= self._config.dispatch_per_minute:
|
|
169
|
+
return AccessResult(
|
|
170
|
+
allowed=False,
|
|
171
|
+
reason=f"Dispatch rate limit exceeded: {self._config.dispatch_per_minute}/min",
|
|
172
|
+
role=self._role,
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
elif operation == Permission.INGEST:
|
|
176
|
+
# Clean old entries
|
|
177
|
+
while self._limits.ingest_bytes and self._limits.ingest_bytes[0][0] < window_start:
|
|
178
|
+
self._limits.ingest_bytes.popleft()
|
|
179
|
+
|
|
180
|
+
total_mb = sum(b for _, b in self._limits.ingest_bytes) / (1024 * 1024)
|
|
181
|
+
incoming_mb = payload_bytes / (1024 * 1024)
|
|
182
|
+
if total_mb + incoming_mb > self._config.ingest_mb_per_minute:
|
|
183
|
+
return AccessResult(
|
|
184
|
+
allowed=False,
|
|
185
|
+
reason=f"Ingest rate limit exceeded: {self._config.ingest_mb_per_minute} MB/min",
|
|
186
|
+
role=self._role,
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# Session token cap
|
|
190
|
+
if self._config.session_token_cap > 0:
|
|
191
|
+
if self._limits.session_tokens_used >= self._config.session_token_cap:
|
|
192
|
+
return AccessResult(
|
|
193
|
+
allowed=False,
|
|
194
|
+
reason=f"Session token cap reached: {self._config.session_token_cap}",
|
|
195
|
+
role=self._role,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return AccessResult(allowed=True, role=self._role)
|
|
199
|
+
|
|
200
|
+
# ── Recording operations ──────────────────────────────────
|
|
201
|
+
|
|
202
|
+
def record_dispatch(self, tokens_used: int = 0) -> None:
|
|
203
|
+
"""Record a dispatch operation for rate limiting."""
|
|
204
|
+
self._limits.dispatch_timestamps.append(time.time())
|
|
205
|
+
self._limits.session_tokens_used += tokens_used
|
|
206
|
+
|
|
207
|
+
def record_ingest(self, payload_bytes: int) -> None:
|
|
208
|
+
"""Record an ingest operation for rate limiting."""
|
|
209
|
+
self._limits.ingest_bytes.append((time.time(), payload_bytes))
|
|
210
|
+
|
|
211
|
+
def record_tokens(self, count: int) -> None:
|
|
212
|
+
"""Record token consumption."""
|
|
213
|
+
self._limits.session_tokens_used += count
|
|
214
|
+
|
|
215
|
+
@property
|
|
216
|
+
def session_tokens_used(self) -> int:
|
|
217
|
+
return self._limits.session_tokens_used
|
|
218
|
+
|
|
219
|
+
def reset_limits(self) -> None:
|
|
220
|
+
"""Reset all rate limit counters."""
|
|
221
|
+
self._limits = RateLimitState()
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""Input validation — Layer 1 structural validation, CANNOT be disabled (§7.4).
|
|
4
|
+
|
|
5
|
+
Enforces:
|
|
6
|
+
- Size limit: 50 MB
|
|
7
|
+
- Unicode NFC normalization
|
|
8
|
+
- Null byte stripping
|
|
9
|
+
- Control character stripping (preserves \\n, \\t, \\r)
|
|
10
|
+
- MIME type validation
|
|
11
|
+
- Metadata key count ≤ 50
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
import unicodedata
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ValidationResult:
|
|
24
|
+
"""Result of input validation."""
|
|
25
|
+
|
|
26
|
+
valid: bool
|
|
27
|
+
sanitized_text: str
|
|
28
|
+
warnings: list[str] = field(default_factory=list)
|
|
29
|
+
original_size: int = 0
|
|
30
|
+
sanitized_size: int = 0
|
|
31
|
+
null_bytes_removed: int = 0
|
|
32
|
+
control_chars_removed: int = 0
|
|
33
|
+
metadata_keys_truncated: int = 0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Allowed MIME types for fact ingestion
|
|
37
|
+
ALLOWED_MIME_TYPES = frozenset({
|
|
38
|
+
"text/plain",
|
|
39
|
+
"text/markdown",
|
|
40
|
+
"text/html",
|
|
41
|
+
"text/csv",
|
|
42
|
+
"application/json",
|
|
43
|
+
"application/xml",
|
|
44
|
+
"text/xml",
|
|
45
|
+
"application/yaml",
|
|
46
|
+
"text/yaml",
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
# Size limits
|
|
50
|
+
MAX_INPUT_SIZE = 50 * 1024 * 1024 # 50 MB
|
|
51
|
+
MAX_METADATA_KEYS = 50
|
|
52
|
+
|
|
53
|
+
# Control character regex: match all control chars except \n \t \r
|
|
54
|
+
_CONTROL_CHAR_RE = re.compile(
|
|
55
|
+
r"[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InputValidator:
|
|
60
|
+
"""Structural input validation — Layer 1, cannot be disabled (§7.4).
|
|
61
|
+
|
|
62
|
+
This validator ALWAYS runs on all input. It cannot be turned off.
|
|
63
|
+
It performs structural sanitization without modifying semantic content.
|
|
64
|
+
|
|
65
|
+
Usage:
|
|
66
|
+
validator = InputValidator()
|
|
67
|
+
result = validator.validate("input text")
|
|
68
|
+
if result.valid:
|
|
69
|
+
use(result.sanitized_text)
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
max_size: int = MAX_INPUT_SIZE,
|
|
75
|
+
max_metadata_keys: int = MAX_METADATA_KEYS,
|
|
76
|
+
) -> None:
|
|
77
|
+
self._max_size = max_size
|
|
78
|
+
self._max_metadata_keys = max_metadata_keys
|
|
79
|
+
|
|
80
|
+
def validate(
|
|
81
|
+
self,
|
|
82
|
+
text: str,
|
|
83
|
+
mime_type: str | None = None,
|
|
84
|
+
metadata: dict[str, Any] | None = None,
|
|
85
|
+
) -> ValidationResult:
|
|
86
|
+
"""Validate and sanitize input text (§7.4).
|
|
87
|
+
|
|
88
|
+
Steps:
|
|
89
|
+
1. Size check (50 MB limit)
|
|
90
|
+
2. Unicode NFC normalization
|
|
91
|
+
3. Null byte stripping
|
|
92
|
+
4. Control character stripping (keep \\n, \\t, \\r)
|
|
93
|
+
5. MIME type validation (if provided)
|
|
94
|
+
6. Metadata key count check (≤ 50)
|
|
95
|
+
"""
|
|
96
|
+
warnings: list[str] = []
|
|
97
|
+
original_size = len(text.encode("utf-8"))
|
|
98
|
+
|
|
99
|
+
# 1. Size limit (§6D.2)
|
|
100
|
+
if original_size > self._max_size:
|
|
101
|
+
return ValidationResult(
|
|
102
|
+
valid=False,
|
|
103
|
+
sanitized_text="",
|
|
104
|
+
warnings=[f"Input exceeds size limit: {original_size} > {self._max_size} bytes"],
|
|
105
|
+
original_size=original_size,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# 2. Unicode NFC normalization (§6D.2)
|
|
109
|
+
sanitized = unicodedata.normalize("NFC", text)
|
|
110
|
+
|
|
111
|
+
# 3. Null byte stripping (§6D.2)
|
|
112
|
+
null_count = sanitized.count("\x00")
|
|
113
|
+
if null_count > 0:
|
|
114
|
+
sanitized = sanitized.replace("\x00", "")
|
|
115
|
+
warnings.append(f"Stripped {null_count} null bytes")
|
|
116
|
+
|
|
117
|
+
# 4. Control character stripping — keep \n \t \r (§6D.3)
|
|
118
|
+
control_matches = _CONTROL_CHAR_RE.findall(sanitized)
|
|
119
|
+
control_count = len(control_matches)
|
|
120
|
+
if control_count > 0:
|
|
121
|
+
sanitized = _CONTROL_CHAR_RE.sub("", sanitized)
|
|
122
|
+
warnings.append(f"Stripped {control_count} control characters")
|
|
123
|
+
|
|
124
|
+
# 5. MIME type validation (§6D.4)
|
|
125
|
+
if mime_type is not None and mime_type not in ALLOWED_MIME_TYPES:
|
|
126
|
+
warnings.append(f"Unrecognized MIME type: {mime_type}")
|
|
127
|
+
|
|
128
|
+
# 6. Metadata key count (§6D.4)
|
|
129
|
+
metadata_truncated = 0
|
|
130
|
+
if metadata is not None and len(metadata) > self._max_metadata_keys:
|
|
131
|
+
metadata_truncated = len(metadata) - self._max_metadata_keys
|
|
132
|
+
warnings.append(
|
|
133
|
+
f"Metadata has {len(metadata)} keys, max {self._max_metadata_keys}; "
|
|
134
|
+
f"excess keys will be ignored"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
sanitized_size = len(sanitized.encode("utf-8"))
|
|
138
|
+
|
|
139
|
+
return ValidationResult(
|
|
140
|
+
valid=True,
|
|
141
|
+
sanitized_text=sanitized,
|
|
142
|
+
warnings=warnings,
|
|
143
|
+
original_size=original_size,
|
|
144
|
+
sanitized_size=sanitized_size,
|
|
145
|
+
null_bytes_removed=null_count,
|
|
146
|
+
control_chars_removed=control_count,
|
|
147
|
+
metadata_keys_truncated=metadata_truncated,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def validate_metadata(self, metadata: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
|
151
|
+
"""Validate and truncate metadata keys (§6D.4).
|
|
152
|
+
|
|
153
|
+
Returns (sanitized_metadata, warnings).
|
|
154
|
+
"""
|
|
155
|
+
warnings: list[str] = []
|
|
156
|
+
if len(metadata) <= self._max_metadata_keys:
|
|
157
|
+
return dict(metadata), warnings
|
|
158
|
+
|
|
159
|
+
warnings.append(
|
|
160
|
+
f"Metadata truncated: {len(metadata)} → {self._max_metadata_keys} keys"
|
|
161
|
+
)
|
|
162
|
+
# Keep first N keys (deterministic order)
|
|
163
|
+
truncated = dict(list(metadata.items())[:self._max_metadata_keys])
|
|
164
|
+
return truncated, warnings
|
crp/state/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Copyright © 2025 Constantinos Vidiniotis. All rights reserved.
|
|
2
|
+
# Licensed under Elastic License 2.0 — see LICENSE.md for details.
|
|
3
|
+
"""State management — facts, warm store, event log, snapshots, cold storage."""
|
|
4
|
+
|
|
5
|
+
from .cold_storage import PersistedStateHeader, persist_to_cold, restore_from_cold
|
|
6
|
+
from .compaction import CompactionConfig, CompactionResult, compact, should_compact # noqa: F401
|
|
7
|
+
from .critical_state import CriticalState, StructuralState
|
|
8
|
+
from .event_log import FactEventLog
|
|
9
|
+
from .fact import StateFact, set_embedding_function
|
|
10
|
+
from .serialization import FactGraphSerializer
|
|
11
|
+
from .snapshot import EventLogSnapshot, SnapshotManager
|
|
12
|
+
from .warm_store import WarmStateStore, WarmStoreConfig
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"CriticalState",
|
|
16
|
+
"CompactionConfig",
|
|
17
|
+
"CompactionResult",
|
|
18
|
+
"compact",
|
|
19
|
+
"EventLogSnapshot",
|
|
20
|
+
"FactEventLog",
|
|
21
|
+
"FactGraphSerializer",
|
|
22
|
+
"SnapshotManager",
|
|
23
|
+
"StateFact",
|
|
24
|
+
"StructuralState",
|
|
25
|
+
"WarmStateStore",
|
|
26
|
+
"WarmStoreConfig",
|
|
27
|
+
"PersistedStateHeader",
|
|
28
|
+
"persist_to_cold",
|
|
29
|
+
"restore_from_cold",
|
|
30
|
+
"set_embedding_function",
|
|
31
|
+
]
|