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,276 @@
1
+ """TraceforgeObserver protocol + concrete GovernanceObserver adapter.
2
+
3
+ ``TraceforgeObserver`` is the integration point host frameworks call around tool
4
+ calls and session lifecycle. ``GovernanceObserver`` is the concrete
5
+ implementation: it runs governance enrichment **synchronously** inside each hook
6
+ (so it can return real ``SessionMeta`` to the host, and so ``observe_event`` — the
7
+ single writer that advances the tool-call budget — runs exactly once per event),
8
+ then hands the enriched record to an :class:`~traceforge.governance.emitter.EnrichedEmitter`
9
+ for asynchronous, backpressure-bounded audit fan-out to sinks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections import defaultdict, deque
16
+ from dataclasses import dataclass
17
+ from datetime import datetime, timezone
18
+ from typing import TYPE_CHECKING, Protocol, runtime_checkable
19
+
20
+ from traceforge.governance.emitter import DEFAULT_CAPACITY, EnrichedEmitter
21
+ from traceforge.governance.pipeline import SessionMeta
22
+
23
+ if TYPE_CHECKING:
24
+ from traceforge.classify.core import Classification
25
+ from traceforge.governance.pipeline import GovernancePipeline
26
+ from traceforge.sinks.base import StorageSink
27
+ from traceforge.types import SessionEvent
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class AgentContext:
34
+ """Context provided by the host framework on session lifecycle events."""
35
+
36
+ session_id: str
37
+ agent_model: str | None = None
38
+ repo: str | None = None
39
+ project_root: str | None = None
40
+
41
+
42
+ @runtime_checkable
43
+ class TraceforgeObserver(Protocol):
44
+ """Protocol for observing agent tool calls with governance enrichment.
45
+
46
+ Implementations receive pre/post tool call events and session lifecycle events.
47
+ Each method returns SessionMeta with full governance analysis.
48
+ """
49
+
50
+ async def on_pre_tool_call(self, tool_name: str, args: dict) -> SessionMeta:
51
+ """Primary classification point."""
52
+ ...
53
+
54
+ async def on_post_tool_call(self, tool_name: str, result: dict) -> SessionMeta:
55
+ """IFC propagation, integrity checks, PII scan of output."""
56
+ ...
57
+
58
+ async def on_session_start(self, context: AgentContext) -> SessionMeta:
59
+ """Called when a new agent session begins."""
60
+ ...
61
+
62
+ async def on_session_end(self, context: AgentContext) -> SessionMeta:
63
+ """Called when an agent session ends."""
64
+ ...
65
+
66
+
67
+ class GovernanceObserver:
68
+ """Concrete :class:`TraceforgeObserver`: sync single-writer enrichment in the
69
+ hooks, async audit emission via :class:`EnrichedEmitter`.
70
+
71
+ **Session-scoped.** Construct one per agent session (or let
72
+ :meth:`on_session_start` set the session id). A monotonic per-session sequence
73
+ is stamped on ``metadata.sequence`` so dropped events can be summarized in a
74
+ :class:`~traceforge.governance.envelope.ContextGapEvent`.
75
+
76
+ **Enrichment cardinality** (the load-bearing invariant):
77
+
78
+ * ``on_session_start`` / ``on_session_end`` → ``observe_event`` routes to
79
+ ``process_lifecycle`` (Phase 1 only; does **not** advance the tool-call
80
+ budget). Stamped + submitted for audit.
81
+ * ``on_pre_tool_call`` → ``score_tool_call_event`` — a **read-only** preflight
82
+ preview (transient state copy; does **not** advance the budget). Its
83
+ ``SessionMeta`` is **returned to the host** for immediate
84
+ classification/enforcement but is **not** emitted to sinks (avoids doubling
85
+ audit output per tool call).
86
+ * ``on_post_tool_call`` → ``observe_event`` routes to ``process_event`` — the
87
+ **single writer** that advances the tool-call budget **exactly once**.
88
+ Stamped + submitted for audit.
89
+
90
+ So across a full ``start → pre → post → end`` cycle the budget advances
91
+ exactly once (at ``post``).
92
+ """
93
+
94
+ def __init__(
95
+ self,
96
+ governance: "GovernancePipeline",
97
+ emitter: EnrichedEmitter,
98
+ *,
99
+ session_id: str | None = None,
100
+ project_root: str | None = None,
101
+ ) -> None:
102
+ self._governance = governance
103
+ self._emitter = emitter
104
+ self._session_id = session_id
105
+ self._project_root = project_root
106
+ self._sequence = 0
107
+ # Positional pre→post argument correlation: the post hook receives only a
108
+ # result, but building the completed event's payload (and shell command
109
+ # analysis) needs the original args. Agents call tools sequentially per
110
+ # session, so a per-tool FIFO of pending args pairs them up.
111
+ self._pending_args: dict[str, deque] = defaultdict(deque)
112
+
113
+ # ── internals ──────────────────────────────────────────────────────────────
114
+
115
+ def _next_sequence(self) -> int:
116
+ self._sequence += 1
117
+ return self._sequence
118
+
119
+ def _require_session(self) -> str:
120
+ if not self._session_id:
121
+ raise RuntimeError(
122
+ "GovernanceObserver has no session_id; call on_session_start first "
123
+ "or pass session_id to the constructor."
124
+ )
125
+ return self._session_id
126
+
127
+ def _classify(
128
+ self, tool_name: str, arguments: dict, server_namespace: str | None
129
+ ) -> "Classification | None":
130
+ """Reuse the blessed self-classifying governance path so the observed
131
+ event carries a real ``Classification`` rather than the UNKNOWN fallback
132
+ ``from_session_event`` would otherwise apply."""
133
+ from traceforge.governance.types import ToolCallEvent
134
+
135
+ gov_event = ToolCallEvent.from_dict(
136
+ {
137
+ "tool_name": tool_name,
138
+ "session_id": self._session_id,
139
+ "tool_input": arguments if isinstance(arguments, dict) else {},
140
+ "server_namespace": server_namespace,
141
+ }
142
+ )
143
+ try:
144
+ return self._governance.enrich_event(gov_event).base_classification
145
+ except Exception as exc: # classification is best-effort; never break the hook
146
+ logger.error("governance classification failed for tool %s: %s", tool_name, exc)
147
+ return None
148
+
149
+ def _build_event(
150
+ self, kind: str, payload: dict, *, classification: "Classification | None" = None
151
+ ) -> "SessionEvent":
152
+ from traceforge.types import EventMetadata, SessionEvent
153
+
154
+ metadata = EventMetadata(sequence=self._next_sequence(), classification=classification)
155
+ return SessionEvent(
156
+ kind=kind,
157
+ session_id=self._require_session(),
158
+ timestamp=datetime.now(timezone.utc),
159
+ payload=payload,
160
+ metadata=metadata,
161
+ )
162
+
163
+ @staticmethod
164
+ def _stamp(event: "SessionEvent", meta: SessionMeta) -> "SessionEvent":
165
+ """Return a copy of ``event`` carrying ``meta`` under ``metadata.governance``.
166
+
167
+ Stamping keeps the backward-compat sink path correct: a sink that only
168
+ implements ``on_event`` still receives governance (the base
169
+ ``on_enriched_event`` forwards the stamped event to ``on_event``)."""
170
+ stamped_meta = event.metadata.model_copy(update={"governance": meta})
171
+ return event.model_copy(update={"metadata": stamped_meta})
172
+
173
+ def _empty_meta(self) -> SessionMeta:
174
+ return SessionMeta(classification=None, risk_assessment=None)
175
+
176
+ # ── TraceforgeObserver hooks ─────────────────────────────────────────────────
177
+
178
+ async def on_session_start(self, context: AgentContext) -> SessionMeta:
179
+ self._session_id = context.session_id
180
+ if context.project_root:
181
+ self._project_root = context.project_root
182
+ from traceforge.types import EventKind
183
+
184
+ event = self._build_event(
185
+ EventKind.SESSION_STARTED,
186
+ {"agent_model": context.agent_model, "repo": context.repo},
187
+ )
188
+ meta = self._governance.observe_event(event) or self._empty_meta()
189
+ self._emitter.submit(self._stamp(event, meta), meta)
190
+ return meta
191
+
192
+ async def on_session_end(self, context: AgentContext) -> SessionMeta:
193
+ if context.session_id:
194
+ self._session_id = context.session_id
195
+ if context.project_root:
196
+ self._project_root = context.project_root
197
+ from traceforge.types import EventKind
198
+
199
+ event = self._build_event(
200
+ EventKind.SESSION_ENDED,
201
+ {"agent_model": context.agent_model, "repo": context.repo},
202
+ )
203
+ meta = self._governance.observe_event(event) or self._empty_meta()
204
+ self._emitter.submit(self._stamp(event, meta), meta)
205
+ return meta
206
+
207
+ async def on_pre_tool_call(self, tool_name: str, args: dict) -> SessionMeta:
208
+ self._require_session()
209
+ from traceforge.types import EventKind
210
+
211
+ server_namespace = args.get("server_namespace") if isinstance(args, dict) else None
212
+ classification = self._classify(tool_name, args, server_namespace)
213
+ event = self._build_event(
214
+ EventKind.TOOL_CALL_STARTED,
215
+ {"tool_name": tool_name, "arguments": args, "server_namespace": server_namespace},
216
+ classification=classification,
217
+ )
218
+ # Remember args so the paired post hook can rebuild the completed event.
219
+ self._pending_args[tool_name].append(args)
220
+ # Read-only preflight preview — advances no budget, returned to host only.
221
+ return self._governance.score_tool_call_event(event)
222
+
223
+ async def on_post_tool_call(self, tool_name: str, result: dict) -> SessionMeta:
224
+ self._require_session()
225
+ from traceforge.types import EventKind
226
+
227
+ pending = self._pending_args.get(tool_name)
228
+ args = pending.popleft() if pending else {}
229
+ server_namespace = args.get("server_namespace") if isinstance(args, dict) else None
230
+ classification = self._classify(tool_name, args, server_namespace)
231
+ event = self._build_event(
232
+ EventKind.TOOL_CALL_COMPLETED,
233
+ {
234
+ "tool_name": tool_name,
235
+ "arguments": args,
236
+ "server_namespace": server_namespace,
237
+ "result": result,
238
+ },
239
+ classification=classification,
240
+ )
241
+ # Single writer: advances the tool-call budget exactly once.
242
+ meta = self._governance.observe_event(event) or self._empty_meta()
243
+ self._emitter.submit(self._stamp(event, meta), meta)
244
+ return meta
245
+
246
+
247
+ def create_observer(
248
+ governance: "GovernancePipeline",
249
+ sinks: "list[StorageSink]",
250
+ *,
251
+ capacity: int = DEFAULT_CAPACITY,
252
+ session_id: str | None = None,
253
+ project_root: str | None = None,
254
+ ) -> "tuple[GovernanceObserver, EnrichedEmitter]":
255
+ """Wire a :class:`GovernanceObserver` + :class:`EnrichedEmitter` against a
256
+ governance pipeline and sinks.
257
+
258
+ The emitter's ``record_drop`` is bound to durable session state so a
259
+ backpressure drop is persisted (``SessionState._dropped_events``) via the
260
+ existing raw-SQLite write-through — no new migration needed.
261
+
262
+ The returned emitter is **not** started; the caller owns its lifecycle and
263
+ must ``await emitter.start()`` inside the event loop that will drive the
264
+ observer, and ``await emitter.aclose()`` at shutdown.
265
+ """
266
+
267
+ def _record_drop(sid: str, n: int) -> None:
268
+ state = governance.get_or_create_state(sid)
269
+ state.record_drop(n)
270
+ state.persist()
271
+
272
+ emitter = EnrichedEmitter(sinks, capacity=capacity, record_drop=_record_drop)
273
+ observer = GovernanceObserver(
274
+ governance, emitter, session_id=session_id, project_root=project_root
275
+ )
276
+ return observer, emitter
@@ -0,0 +1,401 @@
1
+ """SQLite persistence layer for governance session state.
2
+
3
+ Schema is managed by Alembic migrations (src/traceforge/migrations/).
4
+ On initialization, SystemStore runs ``alembic upgrade head`` to apply
5
+ any pending migrations, creating the full normalized schema on a fresh
6
+ database.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import sqlite3
13
+ import threading
14
+ from contextlib import contextmanager
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING
17
+
18
+ if TYPE_CHECKING:
19
+ from collections.abc import Iterator
20
+
21
+ _LRU_CACHE_SIZE = 10_000
22
+
23
+
24
+ def _run_alembic(conn: sqlite3.Connection) -> None:
25
+ """Apply pending Alembic migrations using a SQLAlchemy connection wrapper.
26
+
27
+ Skips the heavy SQLAlchemy/Alembic import if the database is already at
28
+ the latest known revision (fast path for the common case).
29
+ """
30
+ from traceforge.migrations.versions import LATEST_REVISION
31
+
32
+ # Fast path: check if already at head without importing SQLAlchemy
33
+ try:
34
+ cursor = conn.execute("SELECT version_num FROM alembic_version LIMIT 1")
35
+ row = cursor.fetchone()
36
+ if row and row[0] == LATEST_REVISION:
37
+ return # Already at HEAD, skip heavy imports
38
+ except sqlite3.OperationalError:
39
+ pass # Table doesn't exist yet — need full migration
40
+
41
+ # Slow path: need to run migrations
42
+ from sqlalchemy import create_engine, event, pool
43
+
44
+ from traceforge.migrations.runner import run_migrations
45
+
46
+ # Wrap the existing sqlite3 connection in a SQLAlchemy engine so Alembic
47
+ # can drive the migration context without opening a second connection.
48
+ engine = create_engine(
49
+ "sqlite://",
50
+ creator=lambda: conn,
51
+ poolclass=pool.StaticPool,
52
+ )
53
+ # Disable pysqlite's implicit transaction handling so Alembic controls it.
54
+ # We must restore the original isolation_level afterward — otherwise the
55
+ # raw connection is left in permanent autocommit mode, breaking the
56
+ # pipeline's atomic commit/rollback patterns.
57
+ original_isolation_level = conn.isolation_level
58
+
59
+ @event.listens_for(engine, "connect")
60
+ def _set_raw_connection(dbapi_conn, connection_record):
61
+ dbapi_conn.isolation_level = None
62
+
63
+ try:
64
+ with engine.connect() as sa_conn:
65
+ run_migrations(sa_conn)
66
+ finally:
67
+ conn.isolation_level = original_isolation_level
68
+
69
+
70
+ class SystemStore:
71
+ """SQLite-backed persistence for governance state.
72
+
73
+ On construction, runs Alembic migrations to ensure the schema is at HEAD.
74
+
75
+ Single-writer guarantee
76
+ -----------------------
77
+ The store is a *synchronous* durability layer over a single ``sqlite3``
78
+ connection opened with ``check_same_thread=False``. The pipeline is asyncio
79
+ single-threaded and offloads store writes to a worker thread (via
80
+ ``asyncio.to_thread``), so writes can originate on *different* OS threads even
81
+ though they are never logically concurrent per session. An ``asyncio.Queue``
82
+ or ``asyncio.Lock`` cannot serialize work that runs off-loop on arbitrary
83
+ threads, so the correct primitive for this sync store is a
84
+ :class:`threading.Lock` — not a second, async concurrency primitive.
85
+
86
+ Every mutation is serialized by :attr:`_lock`. Single-statement writes take
87
+ the lock around ``execute`` + ``commit``. Multi-statement transactions MUST
88
+ go through :meth:`write_transaction`, which holds the lock for the *entire*
89
+ transaction (not just the terminal ``commit``), guaranteeing that no two
90
+ writers ever interleave statements on the shared connection. This is the one
91
+ and only write-serialization mechanism; callers must never open a second one.
92
+ """
93
+
94
+ def __init__(self, db_path: str | Path) -> None:
95
+ self._db_path = Path(db_path) if str(db_path) != ":memory:" else Path(":memory:")
96
+ if str(db_path) != ":memory:":
97
+ self._db_path.parent.mkdir(parents=True, exist_ok=True)
98
+ self._conn = sqlite3.connect(str(db_path), check_same_thread=False)
99
+ self._lock = threading.Lock()
100
+ self._processed_cache: dict[str, str | None] = {}
101
+
102
+ # Apply WAL and pragmas before migrations
103
+ self._conn.execute("PRAGMA journal_mode = WAL")
104
+ self._conn.execute("PRAGMA busy_timeout = 5000")
105
+ self._conn.execute("PRAGMA synchronous = NORMAL")
106
+
107
+ # Run migrations to HEAD
108
+ _run_alembic(self._conn)
109
+
110
+ @property
111
+ def connection(self) -> sqlite3.Connection:
112
+ return self._conn
113
+
114
+ @property
115
+ def lock(self):
116
+ """Threading lock for callers needing multi-statement transactions."""
117
+ return self._lock
118
+
119
+ @contextmanager
120
+ def write_transaction(self) -> "Iterator[sqlite3.Connection]":
121
+ """Serialize a full multi-statement write transaction under the writer lock.
122
+
123
+ This is the formal single-writer entry point for any write that spans more
124
+ than one statement (state persist + idempotency reservation, deferred MCP
125
+ writes + finalization, etc.). It:
126
+
127
+ * acquires :attr:`_lock` for the whole transaction — every statement the
128
+ body issues on the connection is covered, not merely the final commit —
129
+ so concurrent writers can never interleave on the shared connection;
130
+ * commits on clean exit; and
131
+ * rolls back and re-raises on *any* exception, leaving no dangling
132
+ transaction behind.
133
+
134
+ The body MUST issue its statements without re-acquiring the lock (use
135
+ :meth:`execute_in_transaction` / the ``*_no_commit`` helpers, which do not
136
+ lock). ``_lock`` is a non-reentrant :class:`threading.Lock`, so calling a
137
+ self-locking method (e.g. :meth:`commit`) from inside the body would
138
+ deadlock.
139
+ """
140
+ with self._lock:
141
+ try:
142
+ yield self._conn
143
+ except BaseException:
144
+ self._conn.rollback()
145
+ raise
146
+ else:
147
+ self._conn.commit()
148
+
149
+ def is_duplicate(self, source_event_key: str) -> str | None:
150
+ """Check if event was already processed. Returns cached meta JSON or None."""
151
+ if source_event_key in self._processed_cache:
152
+ return self._processed_cache[source_event_key]
153
+ with self._lock:
154
+ row = self._conn.execute(
155
+ "SELECT session_meta_json FROM processed_events WHERE source_event_key = ?",
156
+ (source_event_key,),
157
+ ).fetchone()
158
+ if row:
159
+ self._processed_cache[source_event_key] = row[0]
160
+ self._evict_cache()
161
+ return row[0]
162
+ return None
163
+
164
+ def record_processed(
165
+ self, source_event_key: str, session_id: str, meta_json: str, processed_at: str
166
+ ) -> None:
167
+ with self._lock:
168
+ self._conn.execute(
169
+ "INSERT OR IGNORE INTO processed_events (source_event_key, session_id, session_meta_json, processed_at) VALUES (?, ?, ?, ?)",
170
+ (source_event_key, session_id, meta_json, processed_at),
171
+ )
172
+ self._conn.commit()
173
+ self._processed_cache[source_event_key] = meta_json
174
+ self._evict_cache()
175
+
176
+ def reserve_event(self, source_event_key: str, session_id: str, processed_at: str) -> None:
177
+ """Reserve an event key before state mutation to prevent double-processing on crash."""
178
+ with self._lock:
179
+ self._conn.execute(
180
+ "INSERT OR IGNORE INTO processed_events (source_event_key, session_id, session_meta_json, processed_at) VALUES (?, ?, ?, ?)",
181
+ (source_event_key, session_id, '{"reserved":true}', processed_at),
182
+ )
183
+ self._conn.commit()
184
+ # Mark as reserved in cache (will be overwritten by finalize)
185
+ self._processed_cache[source_event_key] = '{"reserved":true}'
186
+ self._evict_cache()
187
+
188
+ def finalize_processed(self, source_event_key: str, meta_json: str) -> None:
189
+ """Update reserved event with full meta after Phase 3 completes."""
190
+ with self._lock:
191
+ self._conn.execute(
192
+ "UPDATE processed_events SET session_meta_json = ? WHERE source_event_key = ?",
193
+ (meta_json, source_event_key),
194
+ )
195
+ self._conn.commit()
196
+ self._processed_cache[source_event_key] = meta_json
197
+
198
+ def get_mcp_profile(self, server: str, tool_name: str) -> dict | None:
199
+ """Read an MCP tool profile from the normalized tables.
200
+
201
+ Returns the scalar fingerprint fields plus ``role``/``capability``/
202
+ ``scope`` as sorted lists reconstructed from ``mcp_profile_attributes``,
203
+ or ``None`` when the tool has never been registered.
204
+ """
205
+ with self._lock:
206
+ row = self._conn.execute(
207
+ """SELECT server, tool_name, description_hash, schema_hash, registered_effect,
208
+ clearance, first_seen, last_seen
209
+ FROM mcp_profiles WHERE server = ? AND tool_name = ?""",
210
+ (server, tool_name),
211
+ ).fetchone()
212
+ if not row:
213
+ return None
214
+ attr_rows = self._conn.execute(
215
+ "SELECT attr_type, attr_value FROM mcp_profile_attributes "
216
+ "WHERE server = ? AND tool_name = ?",
217
+ (server, tool_name),
218
+ ).fetchall()
219
+ attributes: dict[str, list[str]] = {"role": [], "capability": [], "scope": []}
220
+ for attr_type, attr_value in attr_rows:
221
+ attributes.setdefault(attr_type, []).append(attr_value)
222
+ return {
223
+ "server": row[0],
224
+ "tool_name": row[1],
225
+ "description_hash": row[2],
226
+ "schema_hash": row[3],
227
+ "registered_effect": row[4],
228
+ "clearance": row[5],
229
+ "first_seen": row[6],
230
+ "last_seen": row[7],
231
+ "role": sorted(attributes["role"]),
232
+ "capability": sorted(attributes["capability"]),
233
+ "scope": sorted(attributes["scope"]),
234
+ }
235
+
236
+ def upsert_mcp_profile(self, server: str, tool_name: str, profile: dict) -> None:
237
+ """Insert MCP profile only if not already registered (preserves first-seen baseline)."""
238
+ with self._lock:
239
+ self.write_mcp_profile_no_commit(server, tool_name, profile)
240
+ self._conn.commit()
241
+
242
+ def write_mcp_profile_no_commit(self, server: str, tool_name: str, profile: dict) -> None:
243
+ """Write an MCP profile within the caller's transaction (no lock, no commit).
244
+
245
+ Writes the scalar ``mcp_profiles`` row plus one ``mcp_profile_attributes``
246
+ row per role/capability/scope value. ``INSERT OR IGNORE`` on every table
247
+ preserves the first-seen baseline (a re-registration of an existing tool
248
+ is a no-op).
249
+ """
250
+ self._conn.execute(
251
+ """INSERT OR IGNORE INTO mcp_profiles
252
+ (server, tool_name, description_hash, schema_hash, registered_effect,
253
+ clearance, first_seen, last_seen)
254
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
255
+ (
256
+ server,
257
+ tool_name,
258
+ profile["description_hash"],
259
+ profile["schema_hash"],
260
+ profile.get("registered_effect"),
261
+ profile.get("clearance"),
262
+ profile["first_seen"],
263
+ profile["last_seen"],
264
+ ),
265
+ )
266
+ for attr_type in ("role", "capability", "scope"):
267
+ for value in profile.get(attr_type) or ():
268
+ self._conn.execute(
269
+ """INSERT OR IGNORE INTO mcp_profile_attributes
270
+ (server, tool_name, attr_type, attr_value) VALUES (?, ?, ?, ?)""",
271
+ (server, tool_name, attr_type, value),
272
+ )
273
+
274
+ def update_mcp_last_seen(self, server: str, tool_name: str, last_seen: str) -> None:
275
+ """Update only last_seen timestamp — preserves registered baseline."""
276
+ with self._lock:
277
+ self.write_mcp_last_seen_no_commit(server, tool_name, last_seen)
278
+ self._conn.commit()
279
+
280
+ def write_mcp_last_seen_no_commit(self, server: str, tool_name: str, last_seen: str) -> None:
281
+ """Bump last_seen on the profile within the caller's transaction."""
282
+ self._conn.execute(
283
+ "UPDATE mcp_profiles SET last_seen = ? WHERE server = ? AND tool_name = ?",
284
+ (last_seen, server, tool_name),
285
+ )
286
+
287
+ def get_budget_counters(self, session_id: str) -> dict[str, dict[str, int]]:
288
+ """Read a session's normalized dimensional budget counters.
289
+
290
+ Returns ``{dimension: {key: count}}`` reconstructed from
291
+ ``budget_counters``. The atomic scalar budget fields (total_tool_calls,
292
+ etc.) are columns on ``session_state``, not part of this projection.
293
+ """
294
+ with self._lock:
295
+ rows = self._conn.execute(
296
+ "SELECT dimension, key, count FROM budget_counters WHERE session_id = ?",
297
+ (session_id,),
298
+ ).fetchall()
299
+ counters: dict[str, dict[str, int]] = {}
300
+ for dimension, key, count in rows:
301
+ counters.setdefault(dimension, {})[key] = count
302
+ return counters
303
+
304
+ def get_taint_entries(self, session_id: str) -> list[dict]:
305
+ """Read a session's normalized taint ledger, ordered by append ordinal."""
306
+ with self._lock:
307
+ rows = self._conn.execute(
308
+ """SELECT event_id, source_event_key, clearance, source, payload_pointer
309
+ FROM taint_entries WHERE session_id = ? ORDER BY ordinal""",
310
+ (session_id,),
311
+ ).fetchall()
312
+ return [
313
+ {
314
+ "event_id": r[0],
315
+ "source_event_key": r[1],
316
+ "clearance": r[2],
317
+ "source": r[3],
318
+ "payload_pointer": r[4],
319
+ }
320
+ for r in rows
321
+ ]
322
+
323
+ def get_content_hash(self, repo: str, file_path: str) -> str | None:
324
+ with self._lock:
325
+ row = self._conn.execute(
326
+ "SELECT sha256 FROM content_hashes WHERE repo = ? AND file_path = ?",
327
+ (repo, file_path),
328
+ ).fetchone()
329
+ return row[0] if row else None
330
+
331
+ def store_content_hash_no_commit(
332
+ self, repo: str, file_path: str, sha256: str, session_id: str, updated_at: str
333
+ ) -> None:
334
+ """Upsert a content-hash baseline within the caller's transaction (no commit).
335
+
336
+ Participates in the caller's open transaction, matching
337
+ :meth:`execute_in_transaction`. Used by the monitor's finalization commit so a
338
+ content-integrity baseline update lands atomically with the idempotency record.
339
+ """
340
+ self._conn.execute(
341
+ """INSERT OR REPLACE INTO content_hashes (repo, file_path, sha256, updated_at, updated_by_session)
342
+ VALUES (?, ?, ?, ?, ?)""",
343
+ (repo, file_path, sha256, updated_at, session_id),
344
+ )
345
+
346
+ def store_content_hash(
347
+ self, repo: str, file_path: str, sha256: str, session_id: str, updated_at: str
348
+ ) -> None:
349
+ with self._lock:
350
+ self.store_content_hash_no_commit(repo, file_path, sha256, session_id, updated_at)
351
+ self._conn.commit()
352
+
353
+ def get_drift_baseline(self, agent_model: str, repo: str) -> dict | None:
354
+ with self._lock:
355
+ row = self._conn.execute(
356
+ "SELECT phase_counts_json, total_events FROM drift_baselines WHERE agent_model = ? AND repo = ?",
357
+ (agent_model, repo),
358
+ ).fetchone()
359
+ if not row:
360
+ return None
361
+ return {"phase_counts": json.loads(row[0]), "total_events": row[1]}
362
+
363
+ def execute_in_transaction(self, sql: str, params: tuple = ()) -> None:
364
+ """Execute SQL within the current transaction (no auto-commit). Caller must hold lock."""
365
+ self._conn.execute(sql, params)
366
+
367
+ def commit(self) -> None:
368
+ """Commit the current transaction."""
369
+ with self._lock:
370
+ self._conn.commit()
371
+
372
+ def rollback(self) -> None:
373
+ """Rollback the current transaction."""
374
+ with self._lock:
375
+ self._conn.rollback()
376
+
377
+ def cache_processed(self, source_event_key: str, meta_json: str | None) -> None:
378
+ """Add entry to processed events cache."""
379
+ self._processed_cache[source_event_key] = meta_json
380
+ self._evict_cache()
381
+
382
+ def commit_deferred_mcp_writes(self, writes: "tuple") -> None:
383
+ """Commit deferred MCP profile writes after pipeline finalization.
384
+
385
+ Accepts tuple[MCPDeferredWrite, ...] from MCPScanResult.
386
+ """
387
+ for write in writes:
388
+ if write.kind == "upsert":
389
+ self.upsert_mcp_profile(write.server, write.tool_name, json.loads(write.payload))
390
+ elif write.kind == "last_seen":
391
+ self.update_mcp_last_seen(write.server, write.tool_name, write.payload)
392
+
393
+ def close(self) -> None:
394
+ self._conn.close()
395
+
396
+ def _evict_cache(self) -> None:
397
+ if len(self._processed_cache) > _LRU_CACHE_SIZE:
398
+ # Simple eviction: remove oldest half
399
+ keys = list(self._processed_cache.keys())
400
+ for k in keys[: len(keys) // 2]:
401
+ del self._processed_cache[k]