traceforge-toolkit 0.1.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 (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,250 @@
1
+ """GovernanceLabeler — Phase 2 of the governance pipeline.
2
+
3
+ Side-effect-free enrichment. ``capability`` and ``structure`` are unioned on
4
+ top of the existing Classification dimensions (labels are cumulative). By
5
+ contrast ``source_labels`` are *dynamic* IFC clearance labels — they are
6
+ computed FRESH for the current event and replace (never union with) whatever
7
+ the base classification carried, matching their exclusion from the canonical
8
+ action hash. Governance risk bonuses are accumulated through
9
+ ``_RiskModifiersBuilder`` and bounded to their documented caps on ``freeze()``.
10
+ Returns GovernanceResult.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import dataclasses
16
+ from dataclasses import dataclass
17
+ from typing import TYPE_CHECKING
18
+
19
+ if TYPE_CHECKING:
20
+ from traceforge.classify.core import Classification
21
+ from traceforge.governance.budget import BudgetThresholds
22
+ from traceforge.governance.drift import DriftAssessment, DriftDetector, DriftResult
23
+ from traceforge.governance.ifc import IFCChecker
24
+ from traceforge.governance.integrity import IntegrityVerifier
25
+ from traceforge.governance.mcp_drift import MCPIntegrityScanner
26
+ from traceforge.governance.pii import PIIScanner
27
+ from traceforge.governance.risk_wrapper import RiskModifiers
28
+ from traceforge.governance.types import EnrichmentContext
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class GovernanceResult:
33
+ """Output of Phase 2 — enriched classification + risk modifiers."""
34
+
35
+ classification: "Classification"
36
+ risk_modifiers: "RiskModifiers"
37
+ drift_result: "DriftResult | None" = None
38
+ mcp_alerts: tuple = () # tuple[MCPIntegrityAlert, ...]
39
+ mcp_deferred_writes: tuple = () # tuple[MCPDeferredWrite, ...] — committed by pipeline after finalization
40
+ integrity_deferred_writes: tuple = () # tuple[IntegrityWrite, ...] — committed by pipeline after finalization
41
+
42
+
43
+ def _bounded(value: int, cap: int) -> int:
44
+ """Clamp ``value`` to the inclusive range ``[0, cap]``."""
45
+ if value < 0:
46
+ return 0
47
+ return cap if value > cap else value
48
+
49
+
50
+ class _RiskModifiersBuilder:
51
+ """Accumulator that bounds governance risk bonuses to their documented caps.
52
+
53
+ Each governance signal contributes additively to a modifier; ``freeze()``
54
+ bounds every accumulated value to the cap documented on the corresponding
55
+ :class:`~traceforge.governance.risk_wrapper.RiskModifiers` field, then
56
+ returns an immutable ``RiskModifiers``. Centralizing the caps here means no
57
+ individual accumulation site can emit a value that violates the
58
+ ``RiskModifiers`` contract — e.g. the drift detector may return a phase
59
+ bonus up to 25, but ``phase_drift_bonus`` is documented "up to 20", so the
60
+ ceiling is enforced exactly once, on freeze.
61
+ """
62
+
63
+ # Documented caps — mirror the RiskModifiers field docstrings.
64
+ PHASE_DRIFT_CAP = 20 # phase_drift_bonus: +10 per anomaly, up to 20
65
+ MCP_DRIFT_CAP = 40 # mcp_drift_bonus: severity-weighted, up to 40
66
+ IFC_VIOLATIONS_CAP = 3 # ifc_violations: +10 per violation up to 30 => 3 counts
67
+ INTEGRITY_CAP = 10 # integrity_bonus: +10 when integrity_unverified
68
+ BUDGET_CAP = 5 # budget_bonus: +5 under budget pressure
69
+
70
+ __slots__ = ("_phase_drift", "_mcp_drift", "_ifc_violations", "_integrity", "_budget")
71
+
72
+ def __init__(self) -> None:
73
+ self._phase_drift = 0
74
+ self._mcp_drift = 0
75
+ self._ifc_violations = 0
76
+ self._integrity = 0
77
+ self._budget = 0
78
+
79
+ def add_phase_drift(self, bonus: int) -> None:
80
+ """Accumulate a phase-drift bonus (bounded to the cap on freeze)."""
81
+ self._phase_drift += bonus
82
+
83
+ def add_mcp_drift(self, bonus: int) -> None:
84
+ """Accumulate an MCP fingerprint-drift bonus (bounded on freeze)."""
85
+ self._mcp_drift += bonus
86
+
87
+ def add_ifc_violation(self, count: int = 1) -> None:
88
+ """Record IFC violation(s); the running count is bounded on freeze."""
89
+ self._ifc_violations += count
90
+
91
+ def set_integrity_unverified(self) -> None:
92
+ """Flag content integrity as unverified (fixed integrity bonus)."""
93
+ self._integrity = self.INTEGRITY_CAP
94
+
95
+ def set_budget_pressure(self) -> None:
96
+ """Flag budget pressure (fixed budget bonus)."""
97
+ self._budget = self.BUDGET_CAP
98
+
99
+ def freeze(self) -> "RiskModifiers":
100
+ """Bound every accumulated modifier to its cap and return ``RiskModifiers``.
101
+
102
+ Pure: does not mutate the builder, so it may be called more than once.
103
+ """
104
+ from traceforge.governance.risk_wrapper import RiskModifiers
105
+
106
+ return RiskModifiers(
107
+ phase_drift_bonus=_bounded(self._phase_drift, self.PHASE_DRIFT_CAP),
108
+ mcp_drift_bonus=_bounded(self._mcp_drift, self.MCP_DRIFT_CAP),
109
+ ifc_violations=_bounded(self._ifc_violations, self.IFC_VIOLATIONS_CAP),
110
+ integrity_bonus=_bounded(self._integrity, self.INTEGRITY_CAP),
111
+ budget_bonus=_bounded(self._budget, self.BUDGET_CAP),
112
+ )
113
+
114
+
115
+ class GovernanceLabeler:
116
+ """Phase 2 labeler. Stateless — all state comes via EnrichmentContext."""
117
+
118
+ def __init__(
119
+ self,
120
+ pii_scanner: "PIIScanner | None" = None,
121
+ integrity_verifier: "IntegrityVerifier | None" = None,
122
+ mcp_scanner: "MCPIntegrityScanner | None" = None,
123
+ ifc_checker: "IFCChecker | None" = None,
124
+ drift_detector: "DriftDetector | None" = None,
125
+ budget_thresholds: "BudgetThresholds | None" = None,
126
+ ) -> None:
127
+ self._pii = pii_scanner
128
+ self._integrity = integrity_verifier
129
+ self._mcp = mcp_scanner
130
+ self._ifc = ifc_checker
131
+ self._drift = drift_detector
132
+ self._budget_thresholds = budget_thresholds
133
+
134
+ def label(self, ctx: "EnrichmentContext") -> GovernanceResult:
135
+ """Enrich classification with governance labels. Side-effect-free."""
136
+ cap: set[str] = set()
137
+ struct: set[str] = set()
138
+ src_labels: set[str] = set()
139
+ modifiers = _RiskModifiersBuilder()
140
+
141
+ # PII scanning
142
+ if self._pii:
143
+ self._pii.scan(ctx, cap, struct)
144
+
145
+ # Content integrity — CHECK against the existing baseline (read-only), then
146
+ # collect deferred (re)baseline prescriptions. The check runs first so drift
147
+ # from a prior baseline is flagged before the monitor commits the new baseline.
148
+ integrity_deferred_writes: tuple = ()
149
+ if self._integrity:
150
+ self._integrity.check_event(ctx, cap)
151
+ integrity_deferred_writes = tuple(self._integrity.pending_writes(ctx))
152
+
153
+ # MCP fingerprint drift — scan returns typed MCPScanResult
154
+ mcp_alerts: tuple = ()
155
+ mcp_deferred_writes: tuple = ()
156
+ if self._mcp:
157
+ scan_result = self._mcp.scan(ctx, cap)
158
+ mcp_alerts = scan_result.alerts
159
+ mcp_deferred_writes = scan_result.deferred_writes
160
+ severity_map = {"critical": 20, "warning": 10, "info": 5}
161
+ modifiers.add_mcp_drift(sum(severity_map[a.severity] for a in mcp_alerts))
162
+ if mcp_alerts:
163
+ struct.add("semantic_drift")
164
+
165
+ # IFC source labels
166
+ if self._ifc and ctx.session_state:
167
+ self._ifc_label_only(ctx, src_labels)
168
+ all_caps = ctx.base_classification.capability | frozenset(cap)
169
+ # Filter out current event's taint to prevent self-violation
170
+ prior_taints = [
171
+ t
172
+ for t in ctx.session_state.taint_ledger
173
+ if t.source_event_key != ctx.event.source_event_key
174
+ ]
175
+ if prior_taints and ctx.base_classification.effect in ("mutating", "destructive"):
176
+ struct.add("ifc_violation")
177
+ modifiers.add_ifc_violation()
178
+ elif any("ifc:" in l for l in src_labels) and "network_outbound" in all_caps:
179
+ struct.add("ifc_violation")
180
+ modifiers.add_ifc_violation()
181
+
182
+ # Phase drift — returns typed DriftAssessment with risk_bonus field
183
+ drift_result: "DriftAssessment | None" = None
184
+ if self._drift and ctx.session_state:
185
+ drift_result = self._drift.detect(ctx, ctx.session_state, cap)
186
+ if drift_result:
187
+ modifiers.add_phase_drift(drift_result.risk_bonus)
188
+ if drift_result.anomaly:
189
+ struct.add("phase_anomaly")
190
+
191
+ # Budget pressure check (read-only from snapshot — no thresholds dependency)
192
+ if ctx.session_state and ctx.session_state.budget.pressure:
193
+ cap.add("budget_pressure")
194
+ modifiers.set_budget_pressure()
195
+
196
+ # Integrity bonus — surfaced by the integrity check above via the cap label.
197
+ if "integrity_unverified" in cap:
198
+ modifiers.set_integrity_unverified()
199
+
200
+ # Merge labels into classification. capability/structure are cumulative
201
+ # (unioned with the base); source_labels are dynamic IFC clearance labels
202
+ # set FRESH for this event (never carried forward from the base).
203
+ enriched = dataclasses.replace(
204
+ ctx.base_classification,
205
+ capability=ctx.base_classification.capability | frozenset(cap),
206
+ structure=ctx.base_classification.structure | frozenset(struct),
207
+ source_labels=frozenset(src_labels),
208
+ )
209
+
210
+ return GovernanceResult(
211
+ classification=enriched,
212
+ risk_modifiers=modifiers.freeze(),
213
+ drift_result=drift_result,
214
+ mcp_alerts=mcp_alerts,
215
+ mcp_deferred_writes=mcp_deferred_writes,
216
+ integrity_deferred_writes=integrity_deferred_writes,
217
+ )
218
+
219
+ def _ifc_label_only(self, ctx: "EnrichmentContext", src_labels: set[str]) -> None:
220
+ """Read-only IFC label assignment from snapshot state."""
221
+ if not ctx.session_state:
222
+ return
223
+ # If there are existing taints and current event writes, propagate label
224
+ if ctx.session_state.taint_ledger:
225
+ max_clearance = max(
226
+ (t.clearance for t in ctx.session_state.taint_ledger),
227
+ key=lambda c: {"public": 0, "internal": 1, "confidential": 2, "secret": 3}.get(
228
+ c, 0
229
+ ),
230
+ default=None,
231
+ )
232
+ if max_clearance:
233
+ src_labels.add(f"ifc:{max_clearance}")
234
+
235
+ # ── Public facade methods for pipeline (eliminate private member access) ──
236
+
237
+ @property
238
+ def has_ifc(self) -> bool:
239
+ """Whether IFC checker is configured."""
240
+ return self._ifc is not None
241
+
242
+ def check_ifc(self, ctx: "EnrichmentContext", src_labels: set[str], state) -> None:
243
+ """Run IFC check (Phase 1 taint propagation). Delegates to IFCChecker."""
244
+ if self._ifc:
245
+ self._ifc.check(ctx, src_labels, state)
246
+
247
+ @property
248
+ def has_mcp_scanner(self) -> bool:
249
+ """Whether MCP integrity scanner is configured."""
250
+ return self._mcp is not None
@@ -0,0 +1,328 @@
1
+ """MCP tool fingerprint drift detection with per-dimension semantic analysis."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from datetime import datetime, timezone
10
+ from typing import TYPE_CHECKING, Literal
11
+
12
+ if TYPE_CHECKING:
13
+ from traceforge.classify.core import Classification
14
+ from traceforge.governance.persistence import SystemStore
15
+ from traceforge.governance.types import EnrichmentContext
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class MCPToolProfile:
20
+ """Frozen fingerprint of an MCP tool at first-seen time."""
21
+
22
+ tool_name: str
23
+ server_namespace: str
24
+ description_hash: str
25
+ schema_hash: str
26
+ registered_effect: str | None
27
+ registered_role: frozenset[str]
28
+ registered_capabilities: frozenset[str]
29
+ registered_scope: frozenset[str]
30
+ clearance: str | None
31
+ first_seen: datetime
32
+ last_seen: datetime
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class MCPIntegrityAlert:
37
+ """Individual drift alert with severity."""
38
+
39
+ tool_name: str
40
+ server: str
41
+ alert_type: Literal[
42
+ "effect_escalation",
43
+ "capability_gain",
44
+ "scope_expansion",
45
+ "description_change",
46
+ "schema_change",
47
+ "adversarial_pattern",
48
+ ]
49
+ previous: str
50
+ current: str
51
+ severity: Literal["info", "warning", "critical"]
52
+ timestamp: datetime
53
+
54
+
55
+ # Effect precedence for escalation detection
56
+ _EFFECT_ORDER = {"read_only": 0, "informational": 1, "mutating": 2, "destructive": 3}
57
+ # Dangerous capabilities that trigger alerts when gained
58
+ _DANGEROUS_CAPS = frozenset(
59
+ {"network_outbound", "elevated_privilege", "arbitrary_execution", "credential_exposure"}
60
+ )
61
+ # Scope escalation (higher = broader)
62
+ _SCOPE_ORDER = {"file": 0, "project": 1, "repository": 2, "host": 3, "network": 4}
63
+
64
+ # Adversarial patterns in tool descriptions
65
+ _ADVERSARIAL_PATTERNS = [
66
+ re.compile(r"[\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff]"), # Invisible unicode
67
+ re.compile(
68
+ r"(?:ignore|disregard|forget)\s+(?:previous|prior|above)\s+(?:instructions?|rules?|constraints?)",
69
+ re.IGNORECASE,
70
+ ), # Prompt injection
71
+ re.compile(r"(?:you\s+are|act\s+as|pretend|role[-\s]?play)", re.IGNORECASE), # Role override
72
+ re.compile(
73
+ r"(?<![A-Fa-f0-9])[A-Za-z0-9+/]{40,}(?:={1,2})(?![A-Za-z0-9+/=])"
74
+ ), # Base64 payload (requires padding, excludes hex-only)
75
+ re.compile(r"<!--.*?-->", re.DOTALL), # Hidden HTML comments
76
+ re.compile(r"\{%.*?%\}", re.DOTALL), # Template injection
77
+ ]
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class MCPDeferredWrite:
82
+ """Immutable deferred DB write — committed only after pipeline finalization."""
83
+
84
+ kind: Literal["upsert", "last_seen"]
85
+ server: str
86
+ tool_name: str
87
+ payload: str # JSON for upsert, ISO timestamp for last_seen
88
+
89
+
90
+ @dataclass(frozen=True)
91
+ class MCPScanResult:
92
+ """Complete scan output — alerts, novelty flag, and deferred writes."""
93
+
94
+ alerts: tuple[MCPIntegrityAlert, ...]
95
+ is_new: bool
96
+ deferred_writes: tuple[MCPDeferredWrite, ...]
97
+
98
+
99
+ class MCPIntegrityScanner:
100
+ """Full MCP integrity scanning: fingerprint drift + semantic analysis + adversarial detection."""
101
+
102
+ def __init__(self, store: "SystemStore") -> None:
103
+ self._store = store
104
+
105
+ def scan(self, ctx: "EnrichmentContext", cap: set[str]) -> MCPScanResult:
106
+ """Full scan: fingerprint comparison + semantic drift + adversarial patterns.
107
+
108
+ Returns MCPScanResult with alerts, is_new flag, and deferred writes.
109
+ Deferred writes MUST only be committed after pipeline finalization.
110
+ """
111
+ from traceforge.governance.types import ToolCallEvent
112
+
113
+ if not isinstance(ctx.event, ToolCallEvent):
114
+ return MCPScanResult(alerts=(), is_new=False, deferred_writes=())
115
+
116
+ server = ctx.event.mcp_server_name or ""
117
+ tool_name = ctx.event.tool_name or ""
118
+ if not server or not tool_name:
119
+ return MCPScanResult(alerts=(), is_new=False, deferred_writes=())
120
+
121
+ alerts: list[MCPIntegrityAlert] = []
122
+ deferred: list[MCPDeferredWrite] = []
123
+ now = datetime.now(timezone.utc)
124
+
125
+ # Compute current fingerprints
126
+ desc_hash = self._hash(ctx.event.tool_description or "")
127
+ schema_hash = self._hash(ctx.event.tool_schema_json or "")
128
+
129
+ # Check stored profile
130
+ stored = self._store.get_mcp_profile(server, tool_name)
131
+
132
+ if stored is None:
133
+ # First time — defer registration until pipeline finalization
134
+ cls = ctx.base_classification
135
+ deferred.append(
136
+ MCPDeferredWrite(
137
+ kind="upsert",
138
+ server=server,
139
+ tool_name=tool_name,
140
+ payload=json.dumps(
141
+ {
142
+ "description_hash": desc_hash,
143
+ "schema_hash": schema_hash,
144
+ "registered_effect": cls.effect,
145
+ "role": sorted(cls.role),
146
+ "capability": sorted(cls.capability),
147
+ "scope": sorted(cls.scope),
148
+ "clearance": None,
149
+ "first_seen": now.isoformat(),
150
+ "last_seen": now.isoformat(),
151
+ }
152
+ ),
153
+ )
154
+ )
155
+ # Still scan description for adversarial content on first-seen
156
+ desc_alerts = self.scan_description(
157
+ ctx.event.tool_description or "", tool_name, server, now
158
+ )
159
+ if desc_alerts:
160
+ alerts.extend(desc_alerts)
161
+ cap.add("mcp_drift")
162
+ return MCPScanResult(
163
+ alerts=tuple(alerts),
164
+ is_new=True,
165
+ deferred_writes=tuple(deferred),
166
+ )
167
+
168
+ # ── Fingerprint comparison ──
169
+ if stored["description_hash"] != desc_hash:
170
+ alerts.append(
171
+ MCPIntegrityAlert(
172
+ tool_name=tool_name,
173
+ server=server,
174
+ alert_type="description_change",
175
+ previous=stored["description_hash"][:16],
176
+ current=desc_hash[:16],
177
+ severity="warning",
178
+ timestamp=now,
179
+ )
180
+ )
181
+
182
+ if stored["schema_hash"] != schema_hash:
183
+ alerts.append(
184
+ MCPIntegrityAlert(
185
+ tool_name=tool_name,
186
+ server=server,
187
+ alert_type="schema_change",
188
+ previous=stored["schema_hash"][:16],
189
+ current=schema_hash[:16],
190
+ severity="critical",
191
+ timestamp=now,
192
+ )
193
+ )
194
+
195
+ # ── Semantic drift: per-dimension comparison ──
196
+ semantic_alerts = self.check_semantic_drift(
197
+ tool_name, server, ctx.base_classification, stored, now
198
+ )
199
+ alerts.extend(semantic_alerts)
200
+
201
+ # ── Adversarial pattern scanning ──
202
+ desc_alerts = self.scan_description(
203
+ ctx.event.tool_description or "", tool_name, server, now
204
+ )
205
+ alerts.extend(desc_alerts)
206
+
207
+ # Apply labels based on alert severity
208
+ if alerts:
209
+ max_severity = max(a.severity for a in alerts)
210
+ if max_severity in ("warning", "critical"):
211
+ cap.add("mcp_drift")
212
+
213
+ # Defer last_seen update until pipeline finalization
214
+ deferred.append(
215
+ MCPDeferredWrite(
216
+ kind="last_seen",
217
+ server=server,
218
+ tool_name=tool_name,
219
+ payload=now.isoformat(),
220
+ )
221
+ )
222
+
223
+ return MCPScanResult(
224
+ alerts=tuple(alerts),
225
+ is_new=False,
226
+ deferred_writes=tuple(deferred),
227
+ )
228
+
229
+ def check_semantic_drift(
230
+ self,
231
+ tool: str,
232
+ server: str,
233
+ current: "Classification",
234
+ stored: dict,
235
+ now: datetime,
236
+ ) -> list[MCPIntegrityAlert]:
237
+ """Per-dimension comparison against registered profile."""
238
+ alerts: list[MCPIntegrityAlert] = []
239
+
240
+ # Effect escalation
241
+ reg_effect = stored.get("registered_effect")
242
+ if reg_effect and current.effect:
243
+ prev_order = _EFFECT_ORDER.get(reg_effect, 0)
244
+ curr_order = _EFFECT_ORDER.get(current.effect, 0)
245
+ if curr_order > prev_order:
246
+ severity = "critical" if curr_order >= 3 else "warning"
247
+ alerts.append(
248
+ MCPIntegrityAlert(
249
+ tool_name=tool,
250
+ server=server,
251
+ alert_type="effect_escalation",
252
+ previous=reg_effect,
253
+ current=current.effect,
254
+ severity=severity,
255
+ timestamp=now,
256
+ )
257
+ )
258
+
259
+ # Capability gain
260
+ reg_caps = frozenset(stored.get("capability") or ())
261
+ new_caps = current.capability - reg_caps
262
+ dangerous_new = new_caps & _DANGEROUS_CAPS
263
+ if dangerous_new:
264
+ alerts.append(
265
+ MCPIntegrityAlert(
266
+ tool_name=tool,
267
+ server=server,
268
+ alert_type="capability_gain",
269
+ previous=",".join(sorted(reg_caps)),
270
+ current=",".join(sorted(dangerous_new)),
271
+ severity="critical",
272
+ timestamp=now,
273
+ )
274
+ )
275
+
276
+ # Scope expansion
277
+ reg_scope = frozenset(stored.get("scope") or ())
278
+ new_scope = current.scope - reg_scope
279
+ if new_scope:
280
+ max_prev = max((_SCOPE_ORDER.get(s, 0) for s in reg_scope), default=0)
281
+ max_curr = max((_SCOPE_ORDER.get(s, 0) for s in new_scope), default=0)
282
+ if max_curr > max_prev:
283
+ alerts.append(
284
+ MCPIntegrityAlert(
285
+ tool_name=tool,
286
+ server=server,
287
+ alert_type="scope_expansion",
288
+ previous=",".join(sorted(reg_scope)),
289
+ current=",".join(sorted(new_scope)),
290
+ severity="warning" if max_curr < 3 else "critical",
291
+ timestamp=now,
292
+ )
293
+ )
294
+
295
+ return alerts
296
+
297
+ def scan_description(
298
+ self,
299
+ description: str,
300
+ tool_name: str,
301
+ server: str,
302
+ now: datetime,
303
+ ) -> list[MCPIntegrityAlert]:
304
+ """Detect adversarial patterns: invisible unicode, prompt injection, base64, hidden markup."""
305
+ if not description:
306
+ return []
307
+
308
+ alerts: list[MCPIntegrityAlert] = []
309
+ for pattern in _ADVERSARIAL_PATTERNS:
310
+ match = pattern.search(description)
311
+ if match:
312
+ alerts.append(
313
+ MCPIntegrityAlert(
314
+ tool_name=tool_name,
315
+ server=server,
316
+ alert_type="adversarial_pattern",
317
+ previous="clean",
318
+ current=f"pattern:{pattern.pattern[:40]}",
319
+ severity="critical",
320
+ timestamp=now,
321
+ )
322
+ )
323
+ break # One alert is enough
324
+
325
+ return alerts
326
+
327
+ def _hash(self, content: str) -> str:
328
+ return hashlib.sha256(content.encode()).hexdigest()