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,414 @@
1
+ """Persistence codec for governance value objects.
2
+
3
+ Bidirectional mapping between :class:`SessionMeta` / ``SessionStateSnapshot`` and the
4
+ JSON-able dicts persisted in the idempotency and audit store. Pure and stateless:
5
+ it owns no store handle and no session residency, only the structural
6
+ (de)serialization the monitor and scorer depend on.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from datetime import datetime, timezone
12
+ from typing import TYPE_CHECKING
13
+
14
+ from traceforge.governance.results import (
15
+ Evidence,
16
+ EvidencePointer,
17
+ EscalationContext,
18
+ RecommendedAction,
19
+ RiskRecommendation,
20
+ SessionMeta,
21
+ TransformSuggestion,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from traceforge.governance.state import SessionStateSnapshot
26
+
27
+
28
+ def _decode_budget_dims(raw: list | None) -> tuple[tuple[str, int], ...]:
29
+ """Safely decode budget dimension pairs from JSON, skipping malformed entries."""
30
+ if not raw:
31
+ return ()
32
+ result = []
33
+ for item in raw:
34
+ if isinstance(item, (list, tuple)) and len(item) == 2:
35
+ key, val = item
36
+ if isinstance(key, str) and isinstance(val, (int, float)):
37
+ result.append((key, int(val)))
38
+ return tuple(result)
39
+
40
+
41
+ class MetaCodec:
42
+ """Serialize and deserialize governance value objects for durable storage."""
43
+
44
+ def serialize_snapshot(self, snapshot: "SessionStateSnapshot") -> dict:
45
+ """Serialize Phase-1 snapshot for reservation persistence."""
46
+ return {
47
+ "budget": {
48
+ "total_tool_calls": snapshot.budget.total_tool_calls,
49
+ "total_tokens": snapshot.budget.total_tokens,
50
+ "elapsed_seconds": snapshot.budget.elapsed_seconds,
51
+ "pressure": snapshot.budget.pressure,
52
+ "by_effect": list(snapshot.budget.by_effect),
53
+ "by_capability": list(snapshot.budget.by_capability),
54
+ "by_scope": list(snapshot.budget.by_scope),
55
+ "by_role": list(snapshot.budget.by_role),
56
+ "by_phase": list(snapshot.budget.by_phase),
57
+ "by_mechanism": list(snapshot.budget.by_mechanism),
58
+ "by_action": list(snapshot.budget.by_action),
59
+ "by_structure": list(snapshot.budget.by_structure),
60
+ },
61
+ "phase_window": list(snapshot.phase_window),
62
+ "taint_ledger": [
63
+ {
64
+ "event_id": t.event_id,
65
+ "source_event_key": t.source_event_key,
66
+ "clearance": t.clearance,
67
+ "source": t.source,
68
+ "payload_pointer": t.payload_pointer,
69
+ }
70
+ for t in snapshot.taint_ledger
71
+ ],
72
+ "last_assistant_event_id": snapshot.last_assistant_event_id,
73
+ "last_user_event_id": snapshot.last_user_event_id,
74
+ "event_count": snapshot.event_count,
75
+ "dropped_events": snapshot.dropped_events,
76
+ "last_sequence": snapshot.last_sequence,
77
+ "gap_ordinal": snapshot.gap_ordinal,
78
+ }
79
+
80
+ def deserialize_snapshot(self, data: dict) -> "SessionStateSnapshot":
81
+ """Reconstruct snapshot from persisted reservation."""
82
+ from traceforge.governance.state import BudgetSnapshot, SessionStateSnapshot, TaintEntry
83
+
84
+ budget_data = data.get("budget", {})
85
+ budget = BudgetSnapshot(
86
+ total_tool_calls=budget_data.get("total_tool_calls", 0),
87
+ total_tokens=budget_data.get("total_tokens", 0),
88
+ elapsed_seconds=budget_data.get("elapsed_seconds", 0.0),
89
+ by_effect=_decode_budget_dims(budget_data.get("by_effect", ())),
90
+ by_capability=_decode_budget_dims(budget_data.get("by_capability", ())),
91
+ by_scope=_decode_budget_dims(budget_data.get("by_scope", ())),
92
+ by_role=_decode_budget_dims(budget_data.get("by_role", ())),
93
+ by_phase=_decode_budget_dims(budget_data.get("by_phase", ())),
94
+ by_mechanism=_decode_budget_dims(budget_data.get("by_mechanism", ())),
95
+ by_action=_decode_budget_dims(budget_data.get("by_action", ())),
96
+ by_structure=_decode_budget_dims(budget_data.get("by_structure", ())),
97
+ pressure=budget_data.get("pressure", False),
98
+ )
99
+ taints = tuple(
100
+ TaintEntry(
101
+ event_id=t["event_id"],
102
+ source_event_key=t.get("source_event_key") or t["event_id"],
103
+ clearance=t["clearance"],
104
+ source=t["source"],
105
+ payload_pointer=t["payload_pointer"],
106
+ )
107
+ for t in data.get("taint_ledger", ())
108
+ )
109
+ return SessionStateSnapshot(
110
+ budget=budget,
111
+ phase_window=tuple(data.get("phase_window", ())),
112
+ taint_ledger=taints,
113
+ last_assistant_event_id=data.get("last_assistant_event_id"),
114
+ last_user_event_id=data.get("last_user_event_id"),
115
+ event_count=data.get("event_count", 0),
116
+ dropped_events=data.get("dropped_events", 0),
117
+ last_sequence=data.get("last_sequence"),
118
+ gap_ordinal=data.get("gap_ordinal", 0),
119
+ )
120
+
121
+ def deserialize_escalation(self, data: dict | None) -> "EscalationContext | None":
122
+ """Reconstruct EscalationContext from cached evidence data."""
123
+ if not data:
124
+ return None
125
+ from traceforge.classify.core import Classification
126
+
127
+ cls = (
128
+ Classification.from_dict(data["classification"]) if data.get("classification") else None
129
+ )
130
+ action_val = data.get("recommended_action", "allow")
131
+ action = (
132
+ RecommendedAction(action_val)
133
+ if action_val in tuple(RecommendedAction)
134
+ else RecommendedAction.ALLOW
135
+ )
136
+ return EscalationContext(
137
+ canonical_id=data.get("canonical_id", ""),
138
+ classification=cls,
139
+ recommended_action=action,
140
+ reason_code=data.get("reason_code", ""),
141
+ mitre_techniques=tuple(data.get("mitre_techniques", ())),
142
+ drift=None, # Drift is session-contextual, not cached
143
+ budget_snapshot=None,
144
+ pii_taint=data.get("pii_taint", False),
145
+ ifc_violations=data.get("ifc_violations", 0),
146
+ tool_name=data.get("tool_name", ""),
147
+ tool_args_summary=data.get("tool_args_summary", ""),
148
+ session_id=data.get("session_id", ""),
149
+ timestamp=datetime.fromisoformat(data["timestamp"])
150
+ if data.get("timestamp")
151
+ else datetime.now(timezone.utc),
152
+ event_id=data.get("event_id", ""),
153
+ classification_summary=data.get("classification_summary", ""),
154
+ risk_factors=tuple(data.get("risk_factors", ())),
155
+ session_event_count=data.get("session_event_count", 0),
156
+ recent_phase_window=tuple(data.get("recent_phase_window", ())),
157
+ )
158
+
159
+ def serialize_meta(self, meta: SessionMeta) -> dict:
160
+ """Full serialization for idempotency cache — preserves all governance decisions."""
161
+ rec_data = None
162
+ if meta.recommendation:
163
+ transform_data = None
164
+ if meta.recommendation.transform:
165
+ transform_data = {
166
+ "target_kind": meta.recommendation.transform.target_kind,
167
+ "path": meta.recommendation.transform.path,
168
+ "original": meta.recommendation.transform.original,
169
+ "replacement": meta.recommendation.transform.replacement,
170
+ "rationale": meta.recommendation.transform.rationale,
171
+ "confidence": meta.recommendation.transform.confidence,
172
+ }
173
+ rec_data = {
174
+ "action": str(meta.recommendation.recommended_action),
175
+ "reason_code": meta.recommendation.reason_code,
176
+ "canonical_id": meta.recommendation.canonical_id,
177
+ "message": meta.recommendation.message,
178
+ "transform": transform_data,
179
+ }
180
+ risk_data = None
181
+ if meta.risk_assessment:
182
+ risk_data = {
183
+ "score": meta.risk_assessment.score,
184
+ "level": meta.risk_assessment.level,
185
+ "confidence": meta.risk_assessment.confidence,
186
+ "factors": list(meta.risk_assessment.factors),
187
+ "mitre": list(meta.risk_assessment.mitre),
188
+ }
189
+ cls_data = None
190
+ if meta.classification:
191
+ cls_data = meta.classification.to_dict()
192
+ budget_data = None
193
+ if meta.budget_snapshot:
194
+ budget_data = {
195
+ "total_tool_calls": meta.budget_snapshot.total_tool_calls,
196
+ "total_tokens": meta.budget_snapshot.total_tokens,
197
+ "elapsed_seconds": meta.budget_snapshot.elapsed_seconds,
198
+ "pressure": meta.budget_snapshot.pressure,
199
+ "by_effect": list(meta.budget_snapshot.by_effect),
200
+ "by_capability": list(meta.budget_snapshot.by_capability),
201
+ "by_scope": list(meta.budget_snapshot.by_scope),
202
+ "by_role": list(meta.budget_snapshot.by_role),
203
+ "by_phase": list(meta.budget_snapshot.by_phase),
204
+ "by_mechanism": list(meta.budget_snapshot.by_mechanism),
205
+ "by_action": list(meta.budget_snapshot.by_action),
206
+ "by_structure": list(meta.budget_snapshot.by_structure),
207
+ }
208
+ evidence_data = None
209
+ if meta.evidence:
210
+ pointers_data = [
211
+ {
212
+ "event_id": p.event_id,
213
+ "rule_id": p.rule_id,
214
+ "detector": p.detector,
215
+ "payload_pointer": p.payload_pointer,
216
+ }
217
+ for p in meta.evidence.pointers
218
+ ]
219
+ evidence_data = {
220
+ "canonical_id": meta.evidence.canonical_id,
221
+ "timestamp": meta.evidence.timestamp.isoformat(),
222
+ "session_id": meta.evidence.session_id,
223
+ "mechanism": meta.evidence.mechanism,
224
+ "effect": meta.evidence.effect,
225
+ "scope": list(meta.evidence.scope),
226
+ "role": list(meta.evidence.role),
227
+ "action": list(meta.evidence.action),
228
+ "capability": list(meta.evidence.capability),
229
+ "structure": list(meta.evidence.structure),
230
+ "source_labels": list(meta.evidence.source_labels),
231
+ "recommended_action": str(meta.evidence.recommended_action),
232
+ "risk_score": meta.evidence.risk_score,
233
+ "risk_factors": list(meta.evidence.risk_factors),
234
+ "mitre_techniques": list(meta.evidence.mitre_techniques),
235
+ "rule_id": meta.evidence.rule_id,
236
+ "matched_predicates": list(meta.evidence.matched_predicates),
237
+ "pointers": pointers_data,
238
+ }
239
+ if meta.evidence.escalation:
240
+ esc = meta.evidence.escalation
241
+ evidence_data["escalation"] = {
242
+ "canonical_id": esc.canonical_id,
243
+ "classification": esc.classification.to_dict() if esc.classification else None,
244
+ "recommended_action": str(esc.recommended_action),
245
+ "reason_code": esc.reason_code,
246
+ "mitre_techniques": list(esc.mitre_techniques),
247
+ "pii_taint": esc.pii_taint,
248
+ "ifc_violations": esc.ifc_violations,
249
+ "tool_name": esc.tool_name,
250
+ "tool_args_summary": esc.tool_args_summary,
251
+ "session_id": esc.session_id,
252
+ "timestamp": esc.timestamp.isoformat(),
253
+ "event_id": esc.event_id,
254
+ "classification_summary": esc.classification_summary,
255
+ "risk_factors": list(esc.risk_factors),
256
+ "session_event_count": esc.session_event_count,
257
+ "recent_phase_window": list(esc.recent_phase_window),
258
+ }
259
+ mcp_alerts_data = []
260
+ if meta.mcp_alerts:
261
+ for alert in meta.mcp_alerts:
262
+ mcp_alerts_data.append(
263
+ {
264
+ "tool_name": alert.tool_name,
265
+ "server": alert.server,
266
+ "alert_type": alert.alert_type,
267
+ "previous": alert.previous,
268
+ "current": alert.current,
269
+ "severity": alert.severity,
270
+ "timestamp": alert.timestamp.isoformat(),
271
+ }
272
+ )
273
+ return {
274
+ "classification": cls_data,
275
+ "recommendation": rec_data,
276
+ "risk": risk_data,
277
+ "budget": budget_data,
278
+ "evidence": evidence_data,
279
+ "mcp_alerts": mcp_alerts_data,
280
+ }
281
+
282
+ def deserialize_meta(self, data: dict) -> SessionMeta:
283
+ """Reconstruct SessionMeta from cache — full fidelity for idempotent output."""
284
+ from traceforge.classify.core import Classification
285
+ from traceforge.classify.risk import RiskAssessment
286
+ from traceforge.governance.state import BudgetSnapshot
287
+
288
+ risk = None
289
+ risk_data = data.get("risk")
290
+ if risk_data:
291
+ risk = RiskAssessment(
292
+ score=risk_data.get("score", 0),
293
+ level=risk_data.get("level", "safe"),
294
+ confidence=risk_data.get("confidence", "medium"),
295
+ factors=tuple(risk_data.get("factors", ())),
296
+ mitre=tuple(risk_data.get("mitre", ())),
297
+ version="cached",
298
+ )
299
+
300
+ rec = None
301
+ rec_data = data.get("recommendation")
302
+ if rec_data and rec_data.get("action"):
303
+ transform = None
304
+ transform_data = rec_data.get("transform")
305
+ if transform_data:
306
+ transform = TransformSuggestion(
307
+ target_kind=transform_data["target_kind"],
308
+ path=transform_data["path"],
309
+ original=transform_data["original"],
310
+ replacement=transform_data.get("replacement"),
311
+ rationale=transform_data.get("rationale", ""),
312
+ confidence=transform_data.get("confidence", "medium"),
313
+ )
314
+ # Only construct recommendation if risk is available (type contract)
315
+ if risk is not None:
316
+ rec = RiskRecommendation(
317
+ recommended_action=RecommendedAction(rec_data["action"]),
318
+ assessment=risk,
319
+ reason_code=rec_data.get("reason_code", ""),
320
+ canonical_id=rec_data.get("canonical_id") or "",
321
+ message=rec_data.get("message"),
322
+ transform=transform,
323
+ )
324
+
325
+ cls = None
326
+ cls_data = data.get("classification")
327
+ if cls_data:
328
+ cls = Classification.from_dict(cls_data)
329
+
330
+ budget = None
331
+ budget_data = data.get("budget")
332
+ if budget_data:
333
+ budget = BudgetSnapshot(
334
+ total_tool_calls=budget_data.get("total_tool_calls", 0),
335
+ total_tokens=budget_data.get("total_tokens", 0),
336
+ elapsed_seconds=budget_data.get("elapsed_seconds", 0.0),
337
+ by_effect=_decode_budget_dims(budget_data.get("by_effect", ())),
338
+ by_capability=_decode_budget_dims(budget_data.get("by_capability", ())),
339
+ by_scope=_decode_budget_dims(budget_data.get("by_scope", ())),
340
+ by_role=_decode_budget_dims(budget_data.get("by_role", ())),
341
+ by_phase=_decode_budget_dims(budget_data.get("by_phase", ())),
342
+ by_mechanism=_decode_budget_dims(budget_data.get("by_mechanism", ())),
343
+ by_action=_decode_budget_dims(budget_data.get("by_action", ())),
344
+ by_structure=_decode_budget_dims(budget_data.get("by_structure", ())),
345
+ pressure=budget_data.get("pressure", False),
346
+ )
347
+
348
+ evidence = None
349
+ evidence_data = data.get("evidence")
350
+ if evidence_data:
351
+ from datetime import datetime as dt_cls
352
+
353
+ pointers = tuple(
354
+ EvidencePointer(
355
+ event_id=p["event_id"],
356
+ rule_id=p["rule_id"],
357
+ detector=p["detector"],
358
+ payload_pointer=p.get("payload_pointer"),
359
+ )
360
+ for p in evidence_data.get("pointers", ())
361
+ )
362
+ evidence = Evidence(
363
+ canonical_id=evidence_data.get("canonical_id", ""),
364
+ timestamp=dt_cls.fromisoformat(evidence_data["timestamp"])
365
+ if evidence_data.get("timestamp")
366
+ else datetime.now(timezone.utc),
367
+ session_id=evidence_data.get("session_id", ""),
368
+ mechanism=evidence_data.get("mechanism", ""),
369
+ effect=evidence_data.get("effect"),
370
+ scope=tuple(evidence_data.get("scope", ())),
371
+ role=tuple(evidence_data.get("role", ())),
372
+ action=tuple(evidence_data.get("action", ())),
373
+ capability=tuple(evidence_data.get("capability", ())),
374
+ structure=tuple(evidence_data.get("structure", ())),
375
+ source_labels=tuple(evidence_data.get("source_labels", ())),
376
+ recommended_action=RecommendedAction(evidence_data["recommended_action"])
377
+ if evidence_data.get("recommended_action") in tuple(RecommendedAction)
378
+ else RecommendedAction.ALLOW,
379
+ risk_score=evidence_data.get("risk_score", 0),
380
+ risk_factors=tuple(evidence_data.get("risk_factors", ())),
381
+ mitre_techniques=tuple(evidence_data.get("mitre_techniques", ())),
382
+ pointers=pointers,
383
+ escalation=self.deserialize_escalation(evidence_data.get("escalation")),
384
+ rule_id=evidence_data.get("rule_id", ""),
385
+ matched_predicates=tuple(evidence_data.get("matched_predicates", ())),
386
+ )
387
+
388
+ from traceforge.governance.mcp_drift import MCPIntegrityAlert
389
+
390
+ mcp_alerts_raw = data.get("mcp_alerts", ())
391
+ mcp_alerts: tuple = tuple(
392
+ MCPIntegrityAlert(
393
+ tool_name=a.get("tool_name", ""),
394
+ server=a.get("server", ""),
395
+ alert_type=a.get("alert_type", "schema_change"),
396
+ previous=a.get("previous", ""),
397
+ current=a.get("current", ""),
398
+ severity=a.get("severity", "info"),
399
+ timestamp=datetime.fromisoformat(a["timestamp"])
400
+ if a.get("timestamp")
401
+ else datetime.now(timezone.utc),
402
+ )
403
+ for a in mcp_alerts_raw
404
+ )
405
+
406
+ return SessionMeta(
407
+ classification=cls,
408
+ risk_assessment=risk,
409
+ recommendation=rec,
410
+ budget_snapshot=budget,
411
+ drift=None,
412
+ mcp_alerts=mcp_alerts,
413
+ evidence=evidence,
414
+ )
@@ -0,0 +1,249 @@
1
+ """Translate raw event shapes into a governance EnrichmentContext.
2
+
3
+ ContextBuilder is the adapter seam between the outside world's event shapes
4
+ (SessionEvent from the observation pipeline; ToolCallEvent from the assess API)
5
+ and the internal EnrichmentContext the Assessor and monitor consume. It is a
6
+ pure translator: it holds only the classification engine and the project root,
7
+ mutates no session state, and never touches the store.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Literal
13
+
14
+ if TYPE_CHECKING:
15
+ import traceforge.types
16
+
17
+ from traceforge.classify.config import ClassificationEngine
18
+ from traceforge.governance.types import EnrichmentContext, ToolCallEvent
19
+
20
+
21
+ class ContextBuilder:
22
+ """Build an EnrichmentContext from raw SessionEvent / ToolCallEvent inputs."""
23
+
24
+ def __init__(self, engine: "ClassificationEngine", project_root: str | None = None) -> None:
25
+ self._engine = engine
26
+ self._project_root = project_root
27
+
28
+ def from_session_event(self, event: "traceforge.types.SessionEvent") -> "EnrichmentContext":
29
+ """Bridge: convert an enriched SessionEvent (from adapters/Enricher) into an EnrichmentContext.
30
+
31
+ This is the canonical path for observation pipeline events. The Enricher
32
+ has already classified the event and stored the result in event.metadata.
33
+ We extract that classification and build the governance context.
34
+ """
35
+ from traceforge.governance.types import EnrichmentContext, ToolCallEvent
36
+
37
+ # Normalize metadata: events may arrive with metadata=None (e.g. pushed
38
+ # directly, bypassing the Enricher). Fall back to an empty EventMetadata so
39
+ # attribute access below never raises.
40
+ metadata = event.metadata
41
+ if metadata is None:
42
+ from traceforge.types import EventMetadata
43
+
44
+ metadata = EventMetadata()
45
+
46
+ # Extract classification (already computed by Enricher)
47
+ classification = metadata.classification
48
+ if classification is None:
49
+ from traceforge.classify.core import Classification, Mechanism
50
+
51
+ classification = Classification(mechanism=Mechanism.UNKNOWN, effect=None)
52
+
53
+ # Build governance ToolCallEvent from SessionEvent fields
54
+ tool_name = event.payload.get("tool_name", "")
55
+ arguments = event.payload.get("arguments", {})
56
+ server_namespace = event.payload.get("server_namespace")
57
+
58
+ import json as _json
59
+
60
+ tool_args_json = (
61
+ _json.dumps(arguments, default=str) if isinstance(arguments, dict) else str(arguments)
62
+ )
63
+
64
+ gov_event = ToolCallEvent(
65
+ event_id=event.id,
66
+ session_id=event.session_id,
67
+ timestamp=event.timestamp,
68
+ source_event_key=event.id,
69
+ span_id=metadata.span_id or event.id,
70
+ tool_name=tool_name,
71
+ server_namespace=server_namespace,
72
+ tool_args_json=tool_args_json,
73
+ source_event_id=None,
74
+ mcp_server_name=event.payload.get("mcp_server_name") or server_namespace,
75
+ tool_description=event.payload.get("tool_description"),
76
+ tool_schema_json=event.payload.get("tool_schema_json"),
77
+ )
78
+
79
+ # Build command analysis for shell tools
80
+ command = ""
81
+ if isinstance(arguments, dict):
82
+ command = arguments.get("command", "") or arguments.get("cmd", "")
83
+ elif isinstance(arguments, str):
84
+ command = arguments
85
+ command_analysis = self._build_command_analysis(command) if command else None
86
+
87
+ # Derive engine literal
88
+ mech_str = (
89
+ classification.mechanism.value
90
+ if hasattr(classification.mechanism, "value")
91
+ else str(classification.mechanism)
92
+ ).lower()
93
+ if "shell" in mech_str or "process" in mech_str:
94
+ engine_literal: Literal["shell", "mcp", "coding"] = "shell"
95
+ elif "mcp" in mech_str:
96
+ engine_literal = "mcp"
97
+ else:
98
+ engine_literal = "coding"
99
+
100
+ return EnrichmentContext(
101
+ event=gov_event,
102
+ base_classification=classification,
103
+ command_analysis=command_analysis,
104
+ session_state=None,
105
+ mcp_profiles=None,
106
+ project_root=self._project_root,
107
+ engine=engine_literal,
108
+ drift_baseline=None,
109
+ mcp_profile_key=server_namespace,
110
+ )
111
+
112
+ def from_tool_call(self, event: "ToolCallEvent") -> "EnrichmentContext":
113
+ """Classify a governance ToolCallEvent and build its EnrichmentContext.
114
+
115
+ Used by the assess pathway (raw dict → ToolCallEvent.from_dict → here).
116
+ For observation pipeline events, use from_session_event instead.
117
+ """
118
+ import json as _json
119
+
120
+ from traceforge.classify.tools import classify_tool, normalize_tool_name
121
+ from traceforge.governance.types import EnrichmentContext
122
+
123
+ tool_name = event.tool_name
124
+ server_namespace = event.server_namespace
125
+
126
+ # MCP namespace synthesis
127
+ classify_name = tool_name
128
+ if server_namespace and not tool_name.startswith("mcp__"):
129
+ prefix = f"{server_namespace}__"
130
+ base = tool_name[len(prefix) :] if tool_name.startswith(prefix) else tool_name
131
+ classify_name = f"mcp__{server_namespace}__{base}"
132
+
133
+ canonical = normalize_tool_name(classify_name, engine=self._engine)
134
+ is_shell = canonical == "shell"
135
+
136
+ if is_shell:
137
+ tool_input = _json.loads(event.tool_args_json) if event.tool_args_json else {}
138
+ command = (
139
+ tool_input.get("command", "") or tool_input.get("cmd", "")
140
+ if isinstance(tool_input, dict)
141
+ else ""
142
+ )
143
+ classification = self._classify_shell_for_assess(tool_name, command)
144
+ command_analysis = self._build_command_analysis(command) if command else None
145
+ else:
146
+ classification = classify_tool(classify_name, engine=self._engine)
147
+ command_analysis = None
148
+
149
+ # Derive engine literal
150
+ mech_str = (
151
+ classification.mechanism.value
152
+ if hasattr(classification.mechanism, "value")
153
+ else str(classification.mechanism)
154
+ ).lower()
155
+ if "shell" in mech_str or "process" in mech_str:
156
+ engine_literal = "shell"
157
+ elif "mcp" in mech_str:
158
+ engine_literal = "mcp"
159
+ else:
160
+ engine_literal = "coding"
161
+
162
+ return EnrichmentContext(
163
+ event=event,
164
+ base_classification=classification,
165
+ command_analysis=command_analysis,
166
+ session_state=None,
167
+ mcp_profiles=None,
168
+ project_root=self._project_root,
169
+ engine=engine_literal,
170
+ drift_baseline=None,
171
+ mcp_profile_key=server_namespace,
172
+ )
173
+
174
+ def _classify_shell_for_assess(self, tool_name: str, command: str):
175
+ """Shell dialect dispatch for assessment."""
176
+ from traceforge.classify.cmd import classify_cmd_command
177
+ from traceforge.classify.coding import CodingMechanism
178
+ from traceforge.classify.core import Classification
179
+ from traceforge.classify.powershell import classify_powershell_command
180
+ from traceforge.classify.shell import classify_shell
181
+
182
+ if not command:
183
+ return Classification(mechanism=CodingMechanism.PROCESS_SHELL, effect=None)
184
+ lower = tool_name.lower()
185
+ if lower in ("powershell", "pwsh"):
186
+ return classify_powershell_command(command, engine=self._engine)
187
+ if lower == "cmd":
188
+ return classify_cmd_command(command, engine=self._engine)
189
+ return classify_shell(command, engine=self._engine)
190
+
191
+ def _build_command_analysis(self, command: str):
192
+ """Build CommandAnalysis using the shell classifier's unwrap logic."""
193
+ import shlex
194
+
195
+ from traceforge.classify.shell import _unwrap_binary
196
+ from traceforge.governance.types import CommandAnalysis, PipeSegment
197
+
198
+ if not command or not command.strip():
199
+ return None
200
+ try:
201
+ tokens = shlex.split(command)
202
+ except ValueError:
203
+ tokens = command.split()
204
+ if not tokens:
205
+ return None
206
+
207
+ binary, _subcmd, flags, _caps = _unwrap_binary(tokens, engine=self._engine)
208
+ targets = tuple(t for t in tokens[1:] if not t.startswith("-") and t != binary)
209
+
210
+ pipe_segments = None
211
+ if "|" in command and "||" not in command and "|&" not in command:
212
+ try:
213
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
214
+ lexer.whitespace_split = False
215
+ all_tokens = list(lexer)
216
+ if "|" in all_tokens:
217
+ segments: list[PipeSegment] = []
218
+ current: list[str] = []
219
+ for tok in all_tokens:
220
+ if tok == "|":
221
+ if current:
222
+ b, _s, f, _c = _unwrap_binary(current, engine=self._engine)
223
+ t = tuple(
224
+ x for x in current[1:] if not x.startswith("-") and x != b
225
+ )
226
+ segments.append(
227
+ PipeSegment(binary=b or current[0], flags=tuple(f), targets=t)
228
+ )
229
+ current = []
230
+ else:
231
+ current.append(tok)
232
+ if current:
233
+ b, _s, f, _c = _unwrap_binary(current, engine=self._engine)
234
+ t = tuple(x for x in current[1:] if not x.startswith("-") and x != b)
235
+ segments.append(
236
+ PipeSegment(binary=b or current[0], flags=tuple(f), targets=t)
237
+ )
238
+ if len(segments) > 1:
239
+ pipe_segments = tuple(segments)
240
+ except ValueError:
241
+ pass
242
+
243
+ return CommandAnalysis(
244
+ command=command,
245
+ binary=binary or tokens[0],
246
+ flags=tuple(flags),
247
+ targets=targets,
248
+ pipe_segments=pipe_segments,
249
+ )