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,466 @@
1
+ """Session state management with SQLite write-through persistence."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sqlite3
7
+ import threading
8
+ from collections import Counter, deque
9
+ from contextlib import nullcontext
10
+ from dataclasses import dataclass, field
11
+ from datetime import datetime, timezone
12
+
13
+ _BUDGET_DIMENSIONS = (
14
+ "effect",
15
+ "mechanism",
16
+ "scope",
17
+ "role",
18
+ "phase",
19
+ "capability",
20
+ "action",
21
+ "structure",
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class TaintEntry:
27
+ """Immutable taint record for IFC lineage tracking."""
28
+
29
+ event_id: str
30
+ source_event_key: str # Unique per event — used for self-taint filtering
31
+ clearance: str # Clearance enum value
32
+ source: str # "file_read", "tool_output", "user_input"
33
+ payload_pointer: str
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class BudgetSnapshot:
38
+ """Immutable point-in-time view of budget counters."""
39
+
40
+ total_tool_calls: int = 0
41
+ total_tokens: int = 0
42
+ elapsed_seconds: float = 0.0
43
+ by_effect: tuple[tuple[str, int], ...] = ()
44
+ by_capability: tuple[tuple[str, int], ...] = ()
45
+ by_scope: tuple[tuple[str, int], ...] = ()
46
+ by_role: tuple[tuple[str, int], ...] = ()
47
+ by_phase: tuple[tuple[str, int], ...] = ()
48
+ by_mechanism: tuple[tuple[str, int], ...] = ()
49
+ by_action: tuple[tuple[str, int], ...] = ()
50
+ by_structure: tuple[tuple[str, int], ...] = ()
51
+ pressure: bool = False
52
+
53
+ def count(self, dimension: str, key: str) -> int:
54
+ """Lookup a count by dimension name and key."""
55
+ dimension_map = {
56
+ "effect": self.by_effect,
57
+ "capability": self.by_capability,
58
+ "scope": self.by_scope,
59
+ "role": self.by_role,
60
+ "phase": self.by_phase,
61
+ "mechanism": self.by_mechanism,
62
+ "action": self.by_action,
63
+ "structure": self.by_structure,
64
+ }
65
+ entries = dimension_map.get(dimension, ())
66
+ for k, v in entries:
67
+ if k == key:
68
+ return v
69
+ return 0
70
+
71
+
72
+ @dataclass(frozen=True)
73
+ class SessionStateSnapshot:
74
+ """Immutable deep-copy of session state, taken after Phase 1 completes."""
75
+
76
+ budget: BudgetSnapshot
77
+ phase_window: tuple[str, ...] = ()
78
+ taint_ledger: tuple[TaintEntry, ...] = ()
79
+ last_assistant_event_id: str | None = None
80
+ last_user_event_id: str | None = None
81
+ event_count: int = 0
82
+ dropped_events: int = 0
83
+ last_sequence: int | None = None
84
+ gap_ordinal: int = 0
85
+
86
+
87
+ @dataclass
88
+ class SessionState:
89
+ """Mutable in-memory session state with SQLite write-through."""
90
+
91
+ session_id: str
92
+ _budget_counters: dict[str, Counter] = field(default_factory=dict)
93
+ _total_tool_calls: int = 0
94
+ _total_tokens: int = 0
95
+ _elapsed_seconds: float = 0.0
96
+ _pressure: bool = False
97
+ _phase_window: list[str] = field(default_factory=list)
98
+ _taint_ledger: list[TaintEntry] = field(default_factory=list)
99
+ _last_assistant_event_id: str | None = None
100
+ _last_user_event_id: str | None = None
101
+ _event_count: int = 0
102
+ _dropped_events: int = 0
103
+ _last_sequence: int | None = None
104
+ _gap_ordinal: int = 0
105
+ _db: sqlite3.Connection | None = None
106
+ # Writer lock shared with the owning SystemStore. When present, a standalone
107
+ # ``persist`` acquires it for the whole transaction so it is serialized against
108
+ # the monitor's ``write_transaction`` on the shared connection (single writer).
109
+ _lock: "threading.Lock | None" = None
110
+ # Shield (enforcement) decision log. The tool-call counter itself is NOT
111
+ # here — it is _total_tool_calls above, advanced only by increment_budget.
112
+ _denied_count: int = 0
113
+ _prior_verdicts: deque = field(default_factory=lambda: deque(maxlen=100))
114
+ _prior_tool_call_ids: deque = field(default_factory=lambda: deque(maxlen=100))
115
+
116
+ PHASE_WINDOW_SIZE: int = 20
117
+ TAINT_LEDGER_MAX: int = 200
118
+
119
+ def __post_init__(self) -> None:
120
+ if not self._budget_counters:
121
+ self._budget_counters = {
122
+ "effect": Counter(),
123
+ "capability": Counter(),
124
+ "scope": Counter(),
125
+ "role": Counter(),
126
+ "phase": Counter(),
127
+ "mechanism": Counter(),
128
+ "action": Counter(),
129
+ "structure": Counter(),
130
+ }
131
+
132
+ def attach_db(
133
+ self, db: sqlite3.Connection | None, lock: "threading.Lock | None" = None
134
+ ) -> None:
135
+ self._db = db
136
+ self._lock = lock
137
+
138
+ def increment_budget(
139
+ self,
140
+ *,
141
+ mechanism: str | None = None,
142
+ effect: str | None = None,
143
+ scope: frozenset[str] = frozenset(),
144
+ role: frozenset[str] = frozenset(),
145
+ action: frozenset[str] = frozenset(),
146
+ capability: frozenset[str] = frozenset(),
147
+ structure: frozenset[str] = frozenset(),
148
+ phase: str | None = None,
149
+ ) -> None:
150
+ """Increment budget counters for a classified event."""
151
+ self._total_tool_calls += 1
152
+ if mechanism:
153
+ self._budget_counters["mechanism"][mechanism] += 1
154
+ if effect:
155
+ self._budget_counters["effect"][effect] += 1
156
+ for v in scope:
157
+ self._budget_counters["scope"][v] += 1
158
+ for v in role:
159
+ self._budget_counters["role"][v] += 1
160
+ for v in action:
161
+ self._budget_counters["action"][v] += 1
162
+ for v in capability:
163
+ self._budget_counters["capability"][v] += 1
164
+ for v in structure:
165
+ self._budget_counters["structure"][v] += 1
166
+ if phase:
167
+ self._budget_counters["phase"][phase] += 1
168
+
169
+ def update_phase_window(self, phase: str) -> None:
170
+ self._phase_window.append(phase)
171
+ if len(self._phase_window) > self.PHASE_WINDOW_SIZE:
172
+ self._phase_window = self._phase_window[-self.PHASE_WINDOW_SIZE :]
173
+
174
+ def add_taint(self, entry: TaintEntry) -> None:
175
+ self._taint_ledger.append(entry)
176
+ if len(self._taint_ledger) > self.TAINT_LEDGER_MAX:
177
+ self._taint_ledger = self._taint_ledger[-self.TAINT_LEDGER_MAX :]
178
+
179
+ def record_event(self, sequence: int | None = None) -> None:
180
+ self._event_count += 1
181
+ if sequence is not None:
182
+ self._last_sequence = sequence
183
+
184
+ def record_drop(self, count: int) -> None:
185
+ self._dropped_events += count
186
+ self._gap_ordinal += 1
187
+
188
+ @property
189
+ def taint_ledger(self) -> list[TaintEntry]:
190
+ """Public read access to taint ledger for IFC checks."""
191
+ return self._taint_ledger
192
+
193
+ def set_last_assistant(self, event_id: str) -> None:
194
+ self._last_assistant_event_id = event_id
195
+
196
+ def set_last_user(self, event_id: str) -> None:
197
+ self._last_user_event_id = event_id
198
+
199
+ # ─── Enforcement (shield) decision tracking ──────────────────────────────
200
+ # The tool-call counter is single-writer: it advances ONLY through
201
+ # increment_budget() when a call is observed. The shield READS
202
+ # tool_call_count and owns only its own denial/allow decision log below.
203
+
204
+ @property
205
+ def tool_call_count(self) -> int:
206
+ """Tool calls observed this session — the single source of truth."""
207
+ return self._total_tool_calls
208
+
209
+ @property
210
+ def denied_count(self) -> int:
211
+ """Tool calls the shield has denied this session."""
212
+ return self._denied_count
213
+
214
+ def prior_verdicts(self) -> tuple:
215
+ """Recent enforcement verdicts (most-recent-last, bounded)."""
216
+ return tuple(self._prior_verdicts)
217
+
218
+ def prior_tool_call_ids(self) -> tuple[str, ...]:
219
+ """Recent allowed tool-call ids (most-recent-last, bounded)."""
220
+ return tuple(self._prior_tool_call_ids)
221
+
222
+ def record_denial(self, verdict) -> None:
223
+ """Record a shield denial. Does NOT touch the tool-call counter."""
224
+ self._denied_count += 1
225
+ self._prior_verdicts.append(verdict) # deque(maxlen=100) auto-evicts
226
+
227
+ def record_allow(self, tool_call_id: str | None = None) -> None:
228
+ """Record a shield allow. Logs the id only — the counter advances via
229
+ observation (increment_budget), never here."""
230
+ if tool_call_id:
231
+ self._prior_tool_call_ids.append(tool_call_id) # deque(maxlen=100)
232
+
233
+ def check_pressure(self, thresholds: dict | None) -> bool:
234
+ """Check if any threshold is exceeded. Updates pressure flag."""
235
+ if not thresholds:
236
+ self._pressure = False
237
+ return False
238
+ max_calls = thresholds.get("max_tool_calls")
239
+ if max_calls and self._total_tool_calls >= max_calls:
240
+ self._pressure = True
241
+ return True
242
+ for dim_key, limits in thresholds.items():
243
+ if dim_key.startswith("max_by_") and isinstance(limits, dict):
244
+ dim = dim_key[len("max_by_") :]
245
+ counter = self._budget_counters.get(dim, Counter())
246
+ for key, limit in limits.items():
247
+ if counter[key] >= limit:
248
+ self._pressure = True
249
+ return True
250
+ self._pressure = False
251
+ return False
252
+
253
+ def clone_detached(self) -> "SessionState":
254
+ """Create a mutable deep copy with no DB connection (for preflight simulation).
255
+
256
+ This is safe to call concurrently — it never touches self._db.
257
+ The returned copy has _db=None so persist calls are no-ops.
258
+ """
259
+ from collections import Counter as _Counter
260
+ from copy import deepcopy
261
+
262
+ return SessionState(
263
+ session_id=self.session_id,
264
+ _budget_counters={k: _Counter(v) for k, v in self._budget_counters.items()},
265
+ _total_tool_calls=self._total_tool_calls,
266
+ _total_tokens=self._total_tokens,
267
+ _elapsed_seconds=self._elapsed_seconds,
268
+ _pressure=self._pressure,
269
+ _phase_window=list(self._phase_window),
270
+ _taint_ledger=deepcopy(self._taint_ledger),
271
+ _last_assistant_event_id=self._last_assistant_event_id,
272
+ _last_user_event_id=self._last_user_event_id,
273
+ _event_count=self._event_count,
274
+ _dropped_events=self._dropped_events,
275
+ _last_sequence=self._last_sequence,
276
+ _gap_ordinal=self._gap_ordinal,
277
+ _db=None,
278
+ )
279
+
280
+ def snapshot(self) -> SessionStateSnapshot:
281
+ """Create frozen snapshot for Phase 2/3."""
282
+ budget = BudgetSnapshot(
283
+ total_tool_calls=self._total_tool_calls,
284
+ total_tokens=self._total_tokens,
285
+ elapsed_seconds=self._elapsed_seconds,
286
+ by_effect=tuple(sorted(self._budget_counters["effect"].items())),
287
+ by_capability=tuple(sorted(self._budget_counters["capability"].items())),
288
+ by_scope=tuple(sorted(self._budget_counters["scope"].items())),
289
+ by_role=tuple(sorted(self._budget_counters["role"].items())),
290
+ by_phase=tuple(sorted(self._budget_counters["phase"].items())),
291
+ by_mechanism=tuple(sorted(self._budget_counters["mechanism"].items())),
292
+ by_action=tuple(sorted(self._budget_counters["action"].items())),
293
+ by_structure=tuple(sorted(self._budget_counters["structure"].items())),
294
+ pressure=self._pressure,
295
+ )
296
+ return SessionStateSnapshot(
297
+ budget=budget,
298
+ phase_window=tuple(self._phase_window),
299
+ taint_ledger=tuple(self._taint_ledger),
300
+ last_assistant_event_id=self._last_assistant_event_id,
301
+ last_user_event_id=self._last_user_event_id,
302
+ event_count=self._event_count,
303
+ dropped_events=self._dropped_events,
304
+ last_sequence=self._last_sequence,
305
+ gap_ordinal=self._gap_ordinal,
306
+ )
307
+
308
+ def _write_state_rows(self) -> None:
309
+ """Write every durable row for this session on the OPEN transaction.
310
+
311
+ Writes the ``session_state`` scalar row plus the normalized projections
312
+ (``budget_counters`` dimensional maps, ``taint_entries`` ledger). Does not
313
+ commit and does not lock — the caller owns both (either :meth:`persist`
314
+ under the writer lock, or the monitor's ``write_transaction``).
315
+ """
316
+ assert self._db is not None # guarded by callers
317
+ now = datetime.now(timezone.utc).isoformat()
318
+ self._db.execute(
319
+ """INSERT OR REPLACE INTO session_state
320
+ (session_id, total_tool_calls, total_tokens, elapsed_seconds, pressure,
321
+ phase_window_json, last_assistant_json, last_user_json,
322
+ event_count, dropped_events, last_sequence, last_event_id, updated_at)
323
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
324
+ (
325
+ self.session_id,
326
+ self._total_tool_calls,
327
+ self._total_tokens,
328
+ self._elapsed_seconds,
329
+ int(self._pressure),
330
+ json.dumps(self._phase_window),
331
+ self._last_assistant_event_id,
332
+ self._last_user_event_id,
333
+ self._event_count,
334
+ self._dropped_events,
335
+ self._last_sequence,
336
+ None,
337
+ now,
338
+ ),
339
+ )
340
+ # Normalized budget counters: full rewrite mirrors the write-through
341
+ # semantics of INSERT OR REPLACE above (the in-memory Counters are the
342
+ # single source of truth for this session).
343
+ self._db.execute("DELETE FROM budget_counters WHERE session_id = ?", (self.session_id,))
344
+ for dimension, counter in self._budget_counters.items():
345
+ for key, count in counter.items():
346
+ self._db.execute(
347
+ "INSERT INTO budget_counters (session_id, dimension, key, count) "
348
+ "VALUES (?, ?, ?, ?)",
349
+ (self.session_id, dimension, key, count),
350
+ )
351
+ # Normalized taint ledger: ordinal preserves append order so the bounded
352
+ # ring-buffer trim behaves identically after a reload.
353
+ self._db.execute("DELETE FROM taint_entries WHERE session_id = ?", (self.session_id,))
354
+ for ordinal, t in enumerate(self._taint_ledger):
355
+ self._db.execute(
356
+ "INSERT INTO taint_entries (session_id, ordinal, event_id, source_event_key, "
357
+ "clearance, source, payload_pointer) VALUES (?, ?, ?, ?, ?, ?, ?)",
358
+ (
359
+ self.session_id,
360
+ ordinal,
361
+ t.event_id,
362
+ t.source_event_key,
363
+ t.clearance,
364
+ t.source,
365
+ t.payload_pointer,
366
+ ),
367
+ )
368
+
369
+ def persist(self) -> None:
370
+ """Write-through to SQLite (self-committing, single-writer safe).
371
+
372
+ Acquires the store's writer lock (when one was attached) for the whole
373
+ transaction, so a standalone persist — e.g. the emitter's drop-recording
374
+ path — is serialized against the monitor's ``write_transaction`` on the
375
+ shared connection instead of racing it.
376
+ """
377
+ if self._db is None:
378
+ return
379
+ with self._lock if self._lock is not None else nullcontext():
380
+ self._write_state_rows()
381
+ self._db.commit()
382
+
383
+ def persist_no_commit(self) -> None:
384
+ """Write state to SQLite WITHOUT committing — caller must commit.
385
+
386
+ Used for atomic state+reservation transactions; the caller (the monitor's
387
+ ``write_transaction``) already holds the writer lock, so this must not lock
388
+ or commit.
389
+ """
390
+ if self._db is None:
391
+ return
392
+ self._write_state_rows()
393
+
394
+ @staticmethod
395
+ def _read_budget_counters(db: sqlite3.Connection, session_id: str) -> dict[str, dict[str, int]]:
396
+ """Read the normalized dimensional counters as ``{dimension: {key: count}}``."""
397
+ rows = db.execute(
398
+ "SELECT dimension, key, count FROM budget_counters WHERE session_id = ?",
399
+ (session_id,),
400
+ ).fetchall()
401
+ counters: dict[str, dict[str, int]] = {}
402
+ for dimension, key, count in rows:
403
+ counters.setdefault(dimension, {})[key] = count
404
+ return counters
405
+
406
+ @staticmethod
407
+ def _read_taint_entries(db: sqlite3.Connection, session_id: str) -> list["TaintEntry"]:
408
+ """Read the normalized taint ledger, ordered by append ordinal."""
409
+ rows = db.execute(
410
+ "SELECT event_id, source_event_key, clearance, source, payload_pointer "
411
+ "FROM taint_entries WHERE session_id = ? ORDER BY ordinal",
412
+ (session_id,),
413
+ ).fetchall()
414
+ return [
415
+ TaintEntry(
416
+ event_id=r[0],
417
+ source_event_key=r[1],
418
+ clearance=r[2],
419
+ source=r[3],
420
+ payload_pointer=r[4],
421
+ )
422
+ for r in rows
423
+ ]
424
+
425
+ @classmethod
426
+ def load_from_db(
427
+ cls,
428
+ session_id: str,
429
+ db: sqlite3.Connection,
430
+ lock: "threading.Lock | None" = None,
431
+ ) -> "SessionState":
432
+ """Load state from SQLite on restart.
433
+
434
+ SQLite is authoritative: the atomic budget scalars are read from
435
+ ``session_state`` columns, the dimensional counters from
436
+ ``budget_counters``, and the taint ledger from ``taint_entries``. The
437
+ whole read runs under the writer lock (when provided) for a consistent
438
+ snapshot against a concurrent ``write_transaction``.
439
+ """
440
+ state = cls(session_id=session_id)
441
+ state.attach_db(db, lock)
442
+ with lock if lock is not None else nullcontext():
443
+ row = db.execute(
444
+ """SELECT total_tool_calls, total_tokens, elapsed_seconds, pressure,
445
+ phase_window_json, last_assistant_json, last_user_json,
446
+ event_count, dropped_events, last_sequence
447
+ FROM session_state WHERE session_id = ?""",
448
+ (session_id,),
449
+ ).fetchone()
450
+ if row is None:
451
+ return state
452
+ state._total_tool_calls = row[0] or 0
453
+ state._total_tokens = row[1] or 0
454
+ state._elapsed_seconds = row[2] or 0.0
455
+ state._pressure = bool(row[3])
456
+ counters = cls._read_budget_counters(db, session_id)
457
+ for dim in _BUDGET_DIMENSIONS:
458
+ state._budget_counters[dim] = Counter(counters.get(dim, {}))
459
+ state._phase_window = json.loads(row[4]) if row[4] else []
460
+ state._last_assistant_event_id = row[5]
461
+ state._last_user_event_id = row[6]
462
+ state._taint_ledger = cls._read_taint_entries(db, session_id)
463
+ state._event_count = row[7] or 0
464
+ state._dropped_events = row[8] or 0
465
+ state._last_sequence = row[9]
466
+ return state
@@ -0,0 +1,176 @@
1
+ """Foundational immutable types for governance enrichment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass
8
+ from datetime import datetime
9
+ from typing import TYPE_CHECKING, Literal
10
+
11
+ from traceforge.classify.core import Classification
12
+
13
+ if TYPE_CHECKING:
14
+ from traceforge.governance.state import SessionStateSnapshot
15
+
16
+
17
+ def _normalize_timestamp(source_timestamp: datetime | str) -> str:
18
+ if isinstance(source_timestamp, datetime):
19
+ return source_timestamp.isoformat()
20
+ return source_timestamp
21
+
22
+
23
+ def _hash_json_payload(payload: dict[str, str]) -> str:
24
+ canonical = json.dumps(payload, sort_keys=True)
25
+ return hashlib.sha256(canonical.encode()).hexdigest()
26
+
27
+
28
+ def compute_source_event_key(
29
+ *,
30
+ session_id: str,
31
+ source_timestamp: datetime | str | None = None,
32
+ source_framework: str | None = None,
33
+ raw_event_id: str | None = None,
34
+ tool_name: str | None = None,
35
+ payload_hash: str | None = None,
36
+ event_kind: str | None = None,
37
+ ) -> str:
38
+ """Compute the stable idempotency key for a source event.
39
+
40
+ Lifecycle events use only ``session_id`` and ``event_kind`` so retries with
41
+ different adapter timestamps map to the same start/end event.
42
+ """
43
+
44
+ if event_kind is not None:
45
+ return f"lifecycle:{session_id}:{event_kind}"
46
+
47
+ if raw_event_id is not None:
48
+ if source_framework is None or source_timestamp is None:
49
+ raise ValueError(
50
+ "source_framework and source_timestamp are required when raw_event_id is set"
51
+ )
52
+ return _hash_json_payload(
53
+ {
54
+ "session_id": session_id,
55
+ "source_framework": source_framework,
56
+ "raw_event_id": raw_event_id,
57
+ "source_timestamp": _normalize_timestamp(source_timestamp),
58
+ }
59
+ )
60
+
61
+ if tool_name is None or source_timestamp is None or payload_hash is None:
62
+ raise ValueError(
63
+ "tool_name, source_timestamp, and payload_hash are required when raw_event_id is absent"
64
+ )
65
+
66
+ return _hash_json_payload(
67
+ {
68
+ "session_id": session_id,
69
+ "tool_name": tool_name,
70
+ "source_timestamp": _normalize_timestamp(source_timestamp),
71
+ "payload_hash": payload_hash,
72
+ }
73
+ )
74
+
75
+
76
+ @dataclass(frozen=True)
77
+ class SessionEvent:
78
+ """Base for all pipeline events."""
79
+
80
+ event_id: str
81
+ session_id: str
82
+ timestamp: datetime
83
+ source_event_key: str
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class ToolCallEvent(SessionEvent):
88
+ """Pre-tool-call event emitted before invocation."""
89
+
90
+ span_id: str
91
+ tool_name: str
92
+ server_namespace: str | None
93
+ tool_args_json: str
94
+ source_event_id: str | None
95
+ mcp_server_name: str | None = None
96
+ tool_description: str | None = None
97
+ tool_schema_json: str | None = None
98
+
99
+ @classmethod
100
+ def from_dict(cls, payload: dict) -> "ToolCallEvent":
101
+ """Build from a raw event dict (any intake channel)."""
102
+ import json as _json
103
+ import uuid as _uuid
104
+ from datetime import datetime as _dt
105
+ from datetime import timezone as _tz
106
+
107
+ if not isinstance(payload, dict):
108
+ payload = {}
109
+ tool_name = str(payload.get("tool_name", "") or "")
110
+ session_id = str(payload.get("session_id", "") or "") or f"anon-{_uuid.uuid4().hex[:8]}"
111
+ tool_input = (
112
+ payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
113
+ )
114
+ eid = f"score-{_uuid.uuid4().hex[:12]}"
115
+
116
+ return cls(
117
+ event_id=eid,
118
+ session_id=session_id,
119
+ timestamp=_dt.now(_tz.utc),
120
+ source_event_key=f"score:{eid}",
121
+ span_id=f"score-span-{_uuid.uuid4().hex[:8]}",
122
+ tool_name=tool_name,
123
+ server_namespace=payload.get("server_namespace"),
124
+ tool_args_json=_json.dumps(tool_input, default=str),
125
+ source_event_id=None,
126
+ mcp_server_name=payload.get("mcp_server_name") or payload.get("server_namespace"),
127
+ tool_description=payload.get("tool_description"),
128
+ tool_schema_json=payload.get("tool_schema_json"),
129
+ )
130
+
131
+
132
+ @dataclass(frozen=True)
133
+ class ToolResultEvent(SessionEvent):
134
+ """Post-tool-call event emitted after invocation completes."""
135
+
136
+ span_id: str
137
+ tool_name: str
138
+ server_namespace: str | None
139
+ result_payload_json: str | None
140
+ result_status: Literal["success", "error", "timeout"]
141
+ pre_call_event_id: str
142
+
143
+
144
+ @dataclass(frozen=True)
145
+ class PipeSegment:
146
+ """Single segment in a shell pipeline."""
147
+
148
+ binary: str
149
+ flags: tuple[str, ...]
150
+ targets: tuple[str, ...]
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class CommandAnalysis:
155
+ """Immutable command details preserved for risk scoring."""
156
+
157
+ command: str | None
158
+ binary: str
159
+ flags: tuple[str, ...]
160
+ targets: tuple[str, ...]
161
+ pipe_segments: tuple[PipeSegment, ...] | None
162
+
163
+
164
+ @dataclass(frozen=True)
165
+ class EnrichmentContext:
166
+ """Read-only inputs available to governance labeling."""
167
+
168
+ event: SessionEvent
169
+ base_classification: Classification
170
+ command_analysis: CommandAnalysis | None
171
+ session_state: "SessionStateSnapshot | None"
172
+ mcp_profiles: dict[str, dict] | None
173
+ project_root: str | None
174
+ engine: Literal["shell", "mcp", "coding"]
175
+ drift_baseline: tuple[tuple[str, float], ...] | None
176
+ mcp_profile_key: str | None
@@ -0,0 +1 @@
1
+ """YAML framework mapping files for MappedJsonAdapter."""