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,840 @@
1
+ """Governance composition-root facade.
2
+
3
+ Wires the governance collaborator graph and forwards the public API to them
4
+ (monitor, scorer, context builder, shield). No governance logic lives here.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from typing import TYPE_CHECKING
11
+
12
+ from traceforge.governance.assessor import DefaultAssessor
13
+ from traceforge.governance.codec import MetaCodec
14
+ from traceforge.governance.context import ContextBuilder
15
+ from traceforge.governance.monitor import SessionMonitor
16
+ from traceforge.governance.phase1 import Phase1
17
+ from traceforge.governance.registry import SessionRegistry
18
+ from traceforge.governance.scorer import Scorer
19
+ from traceforge.governance.shield import Shield
20
+ from traceforge.governance.results import SessionMeta
21
+
22
+ if TYPE_CHECKING:
23
+ import traceforge.types
24
+
25
+ from traceforge.classify.config import ClassificationEngine
26
+ from traceforge.governance.budget import BudgetTracker
27
+ from traceforge.governance.labeler import GovernanceLabeler
28
+ from traceforge.governance.persistence import SystemStore
29
+ from traceforge.governance.rules import Rule
30
+ from traceforge.governance.state import SessionState
31
+ from traceforge.governance.types import (
32
+ EnrichmentContext,
33
+ ToolCallEvent,
34
+ )
35
+ from traceforge.sdk.gate_policy import GatePolicy
36
+ from traceforge.sdk.gate_types import PostflightVerdict
37
+ from traceforge.sdk.verdict import Verdict
38
+
39
+
40
+ def _import_dotted(dotted_path: str):
41
+ """Import a callable from a dotted module path (e.g. 'myapp.policies.my_policy')."""
42
+ module_path, _, attr_name = dotted_path.rpartition(".")
43
+ if not module_path:
44
+ raise ImportError(f"Invalid dotted path: {dotted_path!r} (need module.attr)")
45
+ import importlib
46
+
47
+ module = importlib.import_module(module_path)
48
+ return getattr(module, attr_name)
49
+
50
+
51
+ class GovernancePipeline:
52
+ """Composition-root facade for the governance subsystem.
53
+
54
+ Constructs the collaborator object graph (registry, assessor, phase-1,
55
+ codec, context builder, scorer, shield, monitor) and exposes the public API
56
+ by delegating to them:
57
+
58
+ * observation / lifecycle / state → :class:`SessionMonitor` (the single writer)
59
+ * read-only scoring & previews → :class:`Scorer`
60
+ * event → context bridging → :class:`ContextBuilder`
61
+ * enforcement (preflight/postflight) → :class:`Shield`, to which the
62
+ ``gate_*`` framework adapters bind at the edge.
63
+
64
+ It holds no governance logic of its own — only wiring and forwards.
65
+ """
66
+
67
+ def __init__(
68
+ self,
69
+ store: "SystemStore",
70
+ labeler: "GovernanceLabeler",
71
+ budget_tracker: "BudgetTracker",
72
+ rules: "list[Rule]",
73
+ engine: "ClassificationEngine",
74
+ project_root: str | None = None,
75
+ policy: "GatePolicy | None" = None,
76
+ ) -> None:
77
+ # ── Facade-observable state ──
78
+ self._store = store
79
+ self._project_root = project_root
80
+ self.policy: "GatePolicy | None" = policy
81
+
82
+ # ── Collaborator object graph (composition root; DIP wiring) ──
83
+ self._registry = SessionRegistry(store)
84
+ self._assessor = DefaultAssessor(labeler, rules, engine)
85
+ self._phase1 = Phase1(budget_tracker, labeler)
86
+ self._codec = MetaCodec()
87
+ self._context = ContextBuilder(engine, project_root)
88
+ self._scorer = Scorer(
89
+ self._context,
90
+ self._phase1,
91
+ self._assessor,
92
+ self._registry,
93
+ store,
94
+ self._codec,
95
+ )
96
+ self._shield = Shield(self._registry, lambda: self.policy, self._scorer.score_event)
97
+ self._monitor = SessionMonitor(
98
+ self._context,
99
+ self._phase1,
100
+ self._assessor,
101
+ self._registry,
102
+ store,
103
+ self._codec,
104
+ )
105
+
106
+ @classmethod
107
+ def create(
108
+ cls,
109
+ config: "GovernanceConfig | None" = None,
110
+ *,
111
+ policy: "GatePolicy | None" = None,
112
+ ) -> "GovernancePipeline":
113
+ """Construct a ready-to-use pipeline from config.
114
+
115
+ Usage::
116
+
117
+ from traceforge.governance.pipeline import GovernancePipeline
118
+ from traceforge.sdk import GatePolicy
119
+
120
+ # Zero-config (all defaults)
121
+ pipeline = GovernancePipeline.create()
122
+
123
+ # With gate policy
124
+ policy = GatePolicy().preflight(my_gate)
125
+ pipeline = GovernancePipeline.create(policy=policy)
126
+
127
+ Args:
128
+ config: GovernanceConfig instance. Defaults to GovernanceConfig()
129
+ (in-memory DB, PII scanning on, no budget caps).
130
+ policy: Optional GatePolicy with registered gates.
131
+ """
132
+ from pathlib import Path
133
+
134
+ from traceforge.classify.config import get_default_engine
135
+ from traceforge.config.models import GovernanceConfig
136
+ from traceforge.governance.budget import BudgetThresholds, BudgetTracker
137
+ from traceforge.governance.integrity import IntegrityVerifier
138
+ from traceforge.governance.labeler import GovernanceLabeler
139
+ from traceforge.governance.persistence import SystemStore
140
+ from traceforge.governance.rules import parse_rules
141
+
142
+ if config is None:
143
+ config = GovernanceConfig()
144
+
145
+ store = SystemStore(config.db_path or ":memory:")
146
+ engine = get_default_engine()
147
+
148
+ # Rules: custom path or bundled defaults
149
+ if config.rules_path:
150
+ rules_path = Path(config.rules_path)
151
+ else:
152
+ rules_path = (
153
+ Path(__file__).parent.parent / "classify" / "data" / "recommendation_rules.yaml"
154
+ )
155
+ rules = parse_rules(rules_path)
156
+
157
+ # Budget thresholds from config
158
+ thresholds = BudgetThresholds(
159
+ max_tool_calls=config.budget.max_tool_calls,
160
+ max_by_effect=config.budget.max_by_effect,
161
+ max_by_capability=config.budget.max_by_capability,
162
+ max_by_scope=config.budget.max_by_scope,
163
+ )
164
+
165
+ # PII scanner
166
+ pii_scanner = None
167
+ if config.pii_scanning:
168
+ from traceforge.governance.pii import PIIScanner
169
+
170
+ pii_scanner = PIIScanner()
171
+
172
+ # Content integrity is live by default (opt out via integrity_verification).
173
+ # The verifier is per-event: it derives the repo key from each event's
174
+ # ctx.project_root, so no construction-time repo is needed.
175
+ integrity_verifier = None
176
+ if config.integrity_verification:
177
+ integrity_verifier = IntegrityVerifier(store)
178
+
179
+ instance = cls(
180
+ store=store,
181
+ labeler=GovernanceLabeler(
182
+ pii_scanner=pii_scanner, integrity_verifier=integrity_verifier
183
+ ),
184
+ budget_tracker=BudgetTracker(thresholds=thresholds),
185
+ rules=rules,
186
+ engine=engine,
187
+ policy=policy,
188
+ )
189
+ instance._project_root = config.project_root
190
+ return instance
191
+
192
+ @classmethod
193
+ def from_config(cls, path=None, *, policy: "GatePolicy | None" = None) -> "GovernancePipeline":
194
+ """Create a fully-configured pipeline from a traceforge.yaml file.
195
+
196
+ Args:
197
+ path: Path to traceforge.yaml. None uses standard discovery
198
+ (TRACEFORGE_CONFIG env, ./traceforge.yaml, ~/.traceforge/config.yaml).
199
+ policy: GatePolicy override. If None, loads the preflight gate from config's
200
+ dotted import path (governance.tool_preflight_gate) into a new policy.
201
+
202
+ Usage:
203
+ pipeline = Pipeline.from_config()
204
+ pipeline = Pipeline.from_config(policy=my_policy)
205
+ """
206
+ from traceforge.config.loader import load_config_from_path
207
+
208
+ config = load_config_from_path(path)
209
+
210
+ instance = cls.create(config.governance)
211
+
212
+ # Resolve policy: explicit arg > config dotted path > config external gate > None
213
+ if policy is not None:
214
+ instance.policy = policy
215
+ elif config.governance.tool_preflight_gate:
216
+ from traceforge.sdk.gate_policy import GatePolicy
217
+
218
+ gate_fn = _import_dotted(config.governance.tool_preflight_gate)
219
+ auto_policy = GatePolicy().preflight(gate_fn)
220
+ instance.policy = auto_policy
221
+ elif config.governance.preflight_gate is not None:
222
+ from traceforge.gate.external import HttpGate, SubprocessGate
223
+ from traceforge.sdk.gate_policy import GatePolicy
224
+
225
+ gate_cfg = config.governance.preflight_gate
226
+ if gate_cfg.type == "http":
227
+ gate_fn = HttpGate(
228
+ endpoint=gate_cfg.endpoint,
229
+ timeout=gate_cfg.timeout,
230
+ fail_open=gate_cfg.fail_open,
231
+ headers=dict(gate_cfg.headers) if gate_cfg.headers else None,
232
+ max_input_bytes=gate_cfg.max_input_bytes,
233
+ )
234
+ else: # gate_cfg.type == "subprocess"
235
+ gate_fn = SubprocessGate(
236
+ command=gate_cfg.command,
237
+ timeout=gate_cfg.timeout,
238
+ fail_open=gate_cfg.fail_open,
239
+ max_input_bytes=gate_cfg.max_input_bytes,
240
+ )
241
+ instance.policy = GatePolicy().preflight(gate_fn)
242
+
243
+ return instance
244
+
245
+ def context_from_session_event(
246
+ self, event: "traceforge.types.SessionEvent"
247
+ ) -> "EnrichmentContext":
248
+ """Bridge a SessionEvent into an EnrichmentContext (delegates to ContextBuilder)."""
249
+ return self._context.from_session_event(event)
250
+
251
+ def enrich_event(self, event: "ToolCallEvent") -> "EnrichmentContext":
252
+ """Classify a ToolCallEvent into an EnrichmentContext (delegates to ContextBuilder)."""
253
+ return self._context.from_tool_call(event)
254
+
255
+ def score_tool_call(self, payload: dict) -> "EventTrace":
256
+ """Score a pending tool call against current session state (delegates to Scorer)."""
257
+ return self._scorer.score_tool_call(payload)
258
+
259
+ # ─── Central gate execution helpers ───────────────────────────────────────
260
+
261
+ def _run_preflight(self, trace: "EventTrace", *, session_id: str) -> "Verdict":
262
+ """Forward to the Shield's preflight enforcement chain."""
263
+ return self._shield.run_preflight(trace, session_id=session_id)
264
+
265
+ def _run_postflight(
266
+ self,
267
+ trace: "EventTrace",
268
+ *,
269
+ session_id: str,
270
+ output: dict | None = None,
271
+ duration_ms: int | None = None,
272
+ error: str | None = None,
273
+ ) -> "PostflightVerdict":
274
+ """Forward to the Shield's postflight enforcement chain."""
275
+ return self._shield.run_postflight(
276
+ trace,
277
+ session_id=session_id,
278
+ output=output,
279
+ duration_ms=duration_ms,
280
+ error=error,
281
+ )
282
+
283
+ def score_tool_call_event(self, event: "traceforge.types.SessionEvent") -> "SessionMeta":
284
+ """Score an enriched SessionEvent via the canonical bridge (delegates to Scorer)."""
285
+ return self._scorer.score_tool_call_event(event)
286
+
287
+ def observe_event(self, event: "traceforge.types.SessionEvent") -> "SessionMeta | None":
288
+ """Observation-path scoring stage — the single writer (delegates to SessionMonitor)."""
289
+ return self._monitor.observe_event(event)
290
+
291
+ def preflight_event(self, ctx: "EnrichmentContext") -> "SessionMeta":
292
+ """Preview Phase 1/2/3 without persisting state (delegates to Scorer)."""
293
+ return self._scorer.preflight_event(ctx)
294
+
295
+ def get_or_create_state(self, session_id: str) -> "SessionState":
296
+ """Get or create session state, rehydrating on a miss (delegates to SessionMonitor)."""
297
+ return self._monitor.get_or_create_state(session_id)
298
+
299
+ def process_lifecycle(self, session_id: str, event_kind: str) -> SessionMeta:
300
+ """Handle session_start/end — Phase 1 only (delegates to SessionMonitor)."""
301
+ return self._monitor.process_lifecycle(session_id, event_kind)
302
+
303
+ def process_event(self, ctx: "EnrichmentContext") -> SessionMeta:
304
+ """Full mutating pipeline: Phase 1 -> 2 -> 3 (delegates to SessionMonitor)."""
305
+ return self._monitor.process_event(ctx)
306
+
307
+ # ─── Framework gating methods ────────────────────────────────────────────────
308
+ #
309
+ # Each method integrates traceforge into a framework's native blocking mechanism.
310
+ # Session identity is extracted from the framework's own context — no session_id kwarg.
311
+ # Postflight verdicts (SUPPRESS/TERMINATE/REDACT) are enforced via framework-native signals.
312
+
313
+ def _score_and_gate_preflight(self, payload: dict) -> tuple:
314
+ """Forward to the Shield: score a tool call and run the preflight chain."""
315
+ return self._shield.score_and_gate_preflight(payload)
316
+
317
+ def _enforce_postflight(
318
+ self,
319
+ trace: "EventTrace",
320
+ *,
321
+ session_id: str,
322
+ output: dict | None = None,
323
+ duration_ms: int | None = None,
324
+ error: str | None = None,
325
+ ) -> "PostflightVerdict":
326
+ """Forward to the Shield: observe the completed call, then run postflight."""
327
+ return self._shield.enforce_postflight(
328
+ trace,
329
+ session_id=session_id,
330
+ output=output,
331
+ duration_ms=duration_ms,
332
+ error=error,
333
+ )
334
+
335
+ @staticmethod
336
+ def _apply_postflight_to_output(pv: "PostflightVerdict", result: str) -> str:
337
+ """Forward to the Shield's postflight-output transform."""
338
+ return Shield.apply_postflight_to_output(pv, result)
339
+
340
+ def gate_crewai(self) -> None:
341
+ """Register traceforge into CrewAI's before/after tool_call hooks.
342
+
343
+ Blocking: returns False to CrewAI when preflight returns DENY.
344
+ Session ID: extracted from CrewAI's ctx.crew.fingerprint or generated.
345
+
346
+ WARNING: CrewAI hooks are global. Calling this multiple times registers
347
+ duplicate hooks. Use once per process.
348
+ """
349
+ if getattr(self, "_crewai_gated", False):
350
+ return
351
+ self._crewai_gated = True
352
+
353
+ from crewai.hooks.decorators import after_tool_call, before_tool_call
354
+
355
+ pipeline = self
356
+ # Bounded trace stash — evicts oldest entries to prevent unbounded growth.
357
+ # Max 1000 pending tool calls is generous for any real CrewAI session.
358
+ _traces: dict[str, "EventTrace"] = {}
359
+ _MAX_PENDING = 1000
360
+
361
+ @before_tool_call
362
+ def _traceforge_hook(ctx):
363
+ sid = getattr(getattr(ctx, "crew", None), "fingerprint", None) or "crewai"
364
+ call_id = getattr(ctx, "tool_call_id", None) or f"{ctx.tool_name}:{id(ctx)}"
365
+ payload = {
366
+ "tool_name": ctx.tool_name,
367
+ "tool_input": ctx.tool_input,
368
+ "session_id": sid,
369
+ "tool_call_id": call_id,
370
+ }
371
+ trace, verdict = pipeline._score_and_gate_preflight(payload)
372
+ if verdict.denied:
373
+ return False
374
+ # Stash for postflight (bounded)
375
+ call_key = f"{sid}:{call_id}"
376
+ if len(_traces) >= _MAX_PENDING:
377
+ # Evict oldest entry
378
+ _traces.pop(next(iter(_traces)), None)
379
+ _traces[call_key] = trace
380
+ return None
381
+
382
+ @after_tool_call
383
+ def _traceforge_postflight(ctx):
384
+ sid = getattr(getattr(ctx, "crew", None), "fingerprint", None) or "crewai"
385
+ call_id = getattr(ctx, "tool_call_id", None) or f"{ctx.tool_name}:{id(ctx)}"
386
+ call_key = f"{sid}:{call_id}"
387
+ trace = _traces.pop(call_key, None)
388
+ if trace is None:
389
+ return
390
+ output = getattr(ctx, "output", None)
391
+ pv = pipeline._enforce_postflight(
392
+ trace,
393
+ session_id=sid,
394
+ output={"result": output} if output else None,
395
+ )
396
+ from traceforge.sdk.gate_types import PostflightAction
397
+
398
+ if pv.action == PostflightAction.TERMINATE:
399
+ raise RuntimeError(f"Session terminated by policy: {pv.reason}")
400
+ if pv.action == PostflightAction.SUPPRESS:
401
+ ctx.output = "[output suppressed by policy]"
402
+ elif pv.action == PostflightAction.REDACT and isinstance(output, str):
403
+ ctx.output = pipeline._apply_postflight_to_output(pv, output)
404
+
405
+ def gate_langchain(self, tool):
406
+ """Wrap a LangChain tool's _run with traceforge gating.
407
+
408
+ Blocking: raises ToolException when preflight returns DENY.
409
+ Session ID: uses tool invocation config's configurable.thread_id or "langchain".
410
+ Idempotent: calling twice on same tool is a no-op.
411
+ """
412
+ if getattr(tool, "_traceforge_gated", False):
413
+ return tool
414
+ tool._traceforge_gated = True
415
+
416
+ from langchain_core.tools.base import ToolException
417
+
418
+ pipeline = self
419
+ original_run = tool._run
420
+
421
+ def _guarded_run(*args, config=None, run_manager=None, **kwargs):
422
+ import time
423
+
424
+ sid = "langchain"
425
+ if config and isinstance(config, dict):
426
+ configurable = config.get("configurable", {})
427
+ if isinstance(configurable, dict):
428
+ sid = configurable.get("thread_id", sid)
429
+ elif hasattr(config, "configurable"):
430
+ sid = config.configurable.get("thread_id", sid)
431
+
432
+ # Extract tool-relevant input (exclude LangChain internal keys)
433
+ _INTERNAL_KEYS = {"config", "run_manager", "callbacks", "tags", "metadata"}
434
+ if kwargs:
435
+ tool_input = {k: v for k, v in kwargs.items() if k not in _INTERNAL_KEYS}
436
+ elif args:
437
+ tool_input = {"input": args[0]}
438
+ else:
439
+ tool_input = {}
440
+ payload = {
441
+ "tool_name": tool.name,
442
+ "tool_input": tool_input,
443
+ "session_id": sid,
444
+ }
445
+ trace, verdict = pipeline._score_and_gate_preflight(payload)
446
+ if verdict.denied:
447
+ raise ToolException(f"Denied: {verdict.reason}")
448
+
449
+ t0 = time.monotonic()
450
+ error = None
451
+ result = None
452
+ try:
453
+ result = original_run(*args, config=config, run_manager=run_manager, **kwargs)
454
+ except Exception as exc:
455
+ error = str(exc)
456
+ raise
457
+ finally:
458
+ duration_ms = int((time.monotonic() - t0) * 1000)
459
+ pv = pipeline._enforce_postflight(
460
+ trace,
461
+ session_id=sid,
462
+ output={"result": result} if error is None else None,
463
+ duration_ms=duration_ms,
464
+ error=error,
465
+ )
466
+ from traceforge.sdk.gate_types import PostflightAction
467
+
468
+ if pv.action == PostflightAction.TERMINATE:
469
+ raise RuntimeError(f"Session terminated by policy: {pv.reason}")
470
+ if pv.action == PostflightAction.SUPPRESS:
471
+ return "[output suppressed by policy]"
472
+ if pv.action == PostflightAction.REDACT and isinstance(result, str):
473
+ return pipeline._apply_postflight_to_output(pv, result)
474
+ return result
475
+
476
+ tool._run = _guarded_run
477
+ tool.handle_tool_error = True
478
+ return tool
479
+
480
+ def gate_langgraph(self, tools):
481
+ """Return a ToolNode with traceforge gating via wrap_tool_call.
482
+
483
+ Blocking: returns denial ToolMessage without calling execute.
484
+ Session ID: from request config's configurable.thread_id or "langgraph".
485
+ """
486
+ from langgraph.prebuilt import ToolNode
487
+
488
+ pipeline = self
489
+
490
+ def _traceforge_wrapper(request, execute):
491
+ import time
492
+
493
+ from langchain_core.messages import ToolMessage
494
+
495
+ native_id = request.tool_call.get("id")
496
+ # LangGraph passes thread_id via config
497
+ sid = "langgraph"
498
+ if hasattr(request, "config") and hasattr(request.config, "configurable"):
499
+ sid = request.config.configurable.get("thread_id", sid)
500
+
501
+ payload = {
502
+ "tool_name": request.tool_call["name"],
503
+ "tool_input": request.tool_call["args"],
504
+ "session_id": sid,
505
+ "tool_call_id": native_id,
506
+ }
507
+ trace, verdict = pipeline._score_and_gate_preflight(payload)
508
+ if verdict.denied:
509
+ return ToolMessage(
510
+ content=f"Denied: {verdict.reason}",
511
+ tool_call_id=native_id,
512
+ name=request.tool_call["name"],
513
+ status="error",
514
+ )
515
+
516
+ t0 = time.monotonic()
517
+ error = None
518
+ try:
519
+ result = execute(request)
520
+ except Exception as exc:
521
+ error = str(exc)
522
+ duration_ms = int((time.monotonic() - t0) * 1000)
523
+ pipeline._enforce_postflight(
524
+ trace,
525
+ session_id=sid,
526
+ duration_ms=duration_ms,
527
+ error=error,
528
+ )
529
+ raise
530
+
531
+ duration_ms = int((time.monotonic() - t0) * 1000)
532
+ pv = pipeline._enforce_postflight(
533
+ trace,
534
+ session_id=sid,
535
+ output={"content": getattr(result, "content", str(result))},
536
+ duration_ms=duration_ms,
537
+ )
538
+ from traceforge.sdk.gate_types import PostflightAction
539
+
540
+ if pv.action == PostflightAction.TERMINATE:
541
+ return ToolMessage(
542
+ content=f"Session terminated: {pv.reason}",
543
+ tool_call_id=native_id,
544
+ name=request.tool_call["name"],
545
+ status="error",
546
+ )
547
+ if pv.action == PostflightAction.SUPPRESS:
548
+ return ToolMessage(
549
+ content="[output suppressed by policy]",
550
+ tool_call_id=native_id,
551
+ name=request.tool_call["name"],
552
+ status="success",
553
+ )
554
+ if pv.action == PostflightAction.REDACT:
555
+ content = getattr(result, "content", str(result))
556
+ redacted = pipeline._apply_postflight_to_output(pv, content)
557
+ return ToolMessage(
558
+ content=redacted,
559
+ tool_call_id=native_id,
560
+ name=request.tool_call["name"],
561
+ status="success",
562
+ )
563
+ return result
564
+
565
+ return ToolNode(tools, wrap_tool_call=_traceforge_wrapper)
566
+
567
+ def gate_semantic_kernel(self, kernel) -> None:
568
+ """Register traceforge as a Semantic Kernel auto function invocation filter.
569
+
570
+ Blocking: skips next_handler and injects denial FunctionResult.
571
+ Session ID: from kernel's service_id or "semantic_kernel".
572
+ """
573
+
574
+ pipeline = self
575
+
576
+ @kernel.filter(filter_type="auto_function_invocation")
577
+ async def _traceforge_filter(context, next_handler):
578
+ sid = getattr(kernel, "service_id", None) or "semantic_kernel"
579
+ payload = {
580
+ "tool_name": context.function.name,
581
+ "tool_input": dict(context.arguments) if context.arguments else {},
582
+ "session_id": sid,
583
+ }
584
+ trace, verdict = await asyncio.to_thread(pipeline._score_and_gate_preflight, payload)
585
+ if verdict.denied:
586
+ from semantic_kernel.functions import FunctionResult
587
+
588
+ context.function_result = FunctionResult(
589
+ function=context.function.metadata,
590
+ value=f"Tool blocked by policy: {verdict.reason}",
591
+ )
592
+ context.terminate = True
593
+ return
594
+ await next_handler(context)
595
+ result_val = getattr(context.function_result, "value", None)
596
+ pv = pipeline._enforce_postflight(
597
+ trace,
598
+ session_id=sid,
599
+ output={"result": str(result_val)} if result_val else None,
600
+ )
601
+ from traceforge.sdk.gate_types import PostflightAction
602
+
603
+ if pv.action == PostflightAction.TERMINATE:
604
+ context.terminate = True
605
+ elif pv.action == PostflightAction.SUPPRESS:
606
+ from semantic_kernel.functions import FunctionResult
607
+
608
+ context.function_result = FunctionResult(
609
+ function=context.function.metadata,
610
+ value="[output suppressed by policy]",
611
+ )
612
+ elif pv.action == PostflightAction.REDACT and result_val:
613
+ from semantic_kernel.functions import FunctionResult
614
+
615
+ redacted = pipeline._apply_postflight_to_output(pv, str(result_val))
616
+ context.function_result = FunctionResult(
617
+ function=context.function.metadata,
618
+ value=redacted,
619
+ )
620
+
621
+ def gate_maf(self):
622
+ """Return a FunctionMiddleware for Microsoft Agent Framework (MAF).
623
+
624
+ Blocking: raises MiddlewareTermination or skips call_next to deny.
625
+ Session ID: from context.session.conversation_id or "maf".
626
+ """
627
+ from agent_framework import FunctionMiddleware, MiddlewareTermination
628
+
629
+ pipeline = self
630
+
631
+ class TraceforgeMiddleware(FunctionMiddleware):
632
+ async def process(self, context, call_next):
633
+ import time
634
+
635
+ session = getattr(context, "session", None)
636
+ sid = getattr(session, "conversation_id", None) or "maf"
637
+ payload = {
638
+ "tool_name": context.function.name,
639
+ "tool_input": context.arguments or {},
640
+ "session_id": sid,
641
+ "tool_call_id": getattr(context, "call_id", None),
642
+ }
643
+ trace, verdict = await asyncio.to_thread(
644
+ pipeline._score_and_gate_preflight, payload
645
+ )
646
+ if verdict.denied:
647
+ raise MiddlewareTermination(f"Denied: {verdict.reason}")
648
+
649
+ t0 = time.monotonic()
650
+ error = None
651
+ try:
652
+ await call_next(context)
653
+ except Exception as exc:
654
+ error = str(exc)
655
+ raise
656
+ finally:
657
+ duration_ms = int((time.monotonic() - t0) * 1000)
658
+ result = getattr(context, "result", None)
659
+ pv = pipeline._enforce_postflight(
660
+ trace,
661
+ session_id=sid,
662
+ output={"result": str(result)} if result and not error else None,
663
+ duration_ms=duration_ms,
664
+ error=error,
665
+ )
666
+
667
+ from traceforge.sdk.gate_types import PostflightAction
668
+
669
+ if pv.action == PostflightAction.TERMINATE:
670
+ raise MiddlewareTermination(f"Session terminated: {pv.reason}")
671
+ if pv.action == PostflightAction.SUPPRESS:
672
+ context.result = "[output suppressed by policy]"
673
+ elif pv.action == PostflightAction.REDACT:
674
+ result = getattr(context, "result", None)
675
+ if isinstance(result, str):
676
+ context.result = pipeline._apply_postflight_to_output(pv, result)
677
+
678
+ return TraceforgeMiddleware()
679
+
680
+ def gate_smolagents(self, agent_cls=None):
681
+ """Return a TraceforgeAgent subclass that gates tool calls for smolagents.
682
+
683
+ Blocking: returns denial string as observation without executing the tool.
684
+ Session ID: from agent.session_id or "smolagents".
685
+ """
686
+ if agent_cls is None:
687
+ from smolagents import ToolCallingAgent
688
+
689
+ agent_cls = ToolCallingAgent
690
+
691
+ pipeline = self
692
+
693
+ class _TraceforgeAgent(agent_cls):
694
+ def execute_tool_call(self, tool_name: str, arguments) -> any:
695
+ import time
696
+
697
+ sid = getattr(self, "session_id", None) or "smolagents"
698
+ payload = {
699
+ "tool_name": tool_name,
700
+ "tool_input": arguments if isinstance(arguments, dict) else {"raw": arguments},
701
+ "session_id": sid,
702
+ }
703
+ trace, verdict = pipeline._score_and_gate_preflight(payload)
704
+ if verdict.denied:
705
+ return f"[BLOCKED] {verdict.reason}"
706
+
707
+ t0 = time.monotonic()
708
+ error = None
709
+ result = None
710
+ try:
711
+ result = super().execute_tool_call(tool_name, arguments)
712
+ except Exception as exc:
713
+ error = str(exc)
714
+ raise
715
+ finally:
716
+ duration_ms = int((time.monotonic() - t0) * 1000)
717
+ pv = pipeline._enforce_postflight(
718
+ trace,
719
+ session_id=sid,
720
+ output={"result": str(result)} if error is None else None,
721
+ duration_ms=duration_ms,
722
+ error=error,
723
+ )
724
+ from traceforge.sdk.gate_types import PostflightAction
725
+
726
+ if pv.action == PostflightAction.TERMINATE:
727
+ raise RuntimeError(f"Session terminated by policy: {pv.reason}")
728
+ if pv.action == PostflightAction.SUPPRESS:
729
+ return "[output suppressed by policy]"
730
+ if pv.action == PostflightAction.REDACT and isinstance(result, str):
731
+ return pipeline._apply_postflight_to_output(pv, result)
732
+ return result
733
+
734
+ return _TraceforgeAgent
735
+
736
+ def gate_pydantic_ai(self, agent) -> None:
737
+ """Gate a Pydantic AI agent's tool calls via a wrapping toolset.
738
+
739
+ Blocking: preflight DENY raises ``RuntimeError('Denied: ...')``, which
740
+ propagates out of the agent run so the tool body never executes.
741
+ Session ID: from ``ctx.run_id`` (Pydantic AI's native per-run UUID).
742
+ Postflight: SUPPRESS/REDACT rewrite the tool's return value; TERMINATE raises.
743
+ Idempotent: calling twice on the same agent is a no-op.
744
+
745
+ Pydantic AI (>=1) exposes no tool-execution hooks; the supported per-tool
746
+ interception point is ``AbstractToolset.call_tool``. We wrap each of the
747
+ agent's leaf toolsets (its function tools plus any user/dynamic toolsets) in a
748
+ ``WrapperToolset`` subclass, so every tool call routes through the gate. Because
749
+ preflight and postflight run in the same ``call_tool`` invocation, the trace is
750
+ a local variable — no cross-hook stash is needed. Apply after tools are
751
+ registered.
752
+ """
753
+ if getattr(agent, "_traceforge_gated", False):
754
+ return
755
+
756
+ from pydantic_ai.toolsets import WrapperToolset
757
+
758
+ pipeline = self
759
+
760
+ class _TraceforgeGateToolset(WrapperToolset):
761
+ async def call_tool(self, name, tool_args, ctx, tool):
762
+ from traceforge.sdk.gate_types import PostflightAction
763
+
764
+ sid = str(getattr(ctx, "run_id", None) or "pydantic_ai")
765
+ payload = {
766
+ "tool_name": name,
767
+ "tool_input": tool_args if isinstance(tool_args, dict) else {"raw": tool_args},
768
+ "session_id": sid,
769
+ "tool_call_id": getattr(ctx, "tool_call_id", None),
770
+ }
771
+ trace, verdict = await asyncio.to_thread(
772
+ pipeline._score_and_gate_preflight, payload
773
+ )
774
+ if verdict.denied:
775
+ raise RuntimeError(f"Denied: {verdict.reason}")
776
+
777
+ result = await super().call_tool(name, tool_args, ctx, tool)
778
+
779
+ pv = pipeline._enforce_postflight(
780
+ trace,
781
+ session_id=sid,
782
+ output={"result": str(result)} if result is not None else None,
783
+ )
784
+ if pv.action == PostflightAction.TERMINATE:
785
+ raise RuntimeError(f"Session terminated by policy: {pv.reason}")
786
+ if pv.action == PostflightAction.SUPPRESS:
787
+ return "[output suppressed by policy]"
788
+ if pv.action == PostflightAction.REDACT and isinstance(result, str):
789
+ return pipeline._apply_postflight_to_output(pv, result)
790
+ return result
791
+
792
+ # Wrap every leaf toolset the agent will assemble into its run toolset, then
793
+ # mark gated only after wrapping succeeds (no half-gated state on failure).
794
+ agent._function_toolset = _TraceforgeGateToolset(agent._function_toolset)
795
+ agent._user_toolsets = [_TraceforgeGateToolset(ts) for ts in agent._user_toolsets]
796
+ agent._dynamic_toolsets = [_TraceforgeGateToolset(ts) for ts in agent._dynamic_toolsets]
797
+ agent._traceforge_gated = True
798
+
799
+ def gate_openai_agents(self, agent):
800
+ """Register traceforge as an OpenAI Agents SDK input guardrail.
801
+
802
+ Blocking: raises GuardrailTripwireTriggered which rejects the entire turn.
803
+ Session ID: from agent.name or "openai_agents".
804
+ Idempotent: calling twice on same agent is a no-op.
805
+
806
+ NOTE: Input guardrails fire on the agent's input message, NOT on individual
807
+ tool calls. For per-tool-call gating, use needs_approval=True on tools and
808
+ integrate via the approval handler pattern (see gating spec §5b).
809
+ """
810
+ if getattr(agent, "_traceforge_gated", False):
811
+ return agent
812
+ agent._traceforge_gated = True
813
+
814
+ pipeline = self
815
+
816
+ from agents import input_guardrail, GuardrailFunctionOutput
817
+
818
+ @input_guardrail
819
+ async def traceforge_guardrail(ctx, agent_instance, input_data):
820
+ sid = getattr(agent_instance, "name", None) or "openai_agents"
821
+ payload = {
822
+ "tool_name": getattr(input_data, "tool_name", "unknown"),
823
+ "tool_input": getattr(input_data, "tool_input", {}),
824
+ "session_id": sid,
825
+ }
826
+ trace, verdict = await asyncio.to_thread(pipeline._score_and_gate_preflight, payload)
827
+ if verdict.denied:
828
+ return GuardrailFunctionOutput(
829
+ output_info=verdict.reason,
830
+ tripwire_triggered=True,
831
+ )
832
+ return GuardrailFunctionOutput(
833
+ output_info="allowed",
834
+ tripwire_triggered=False,
835
+ )
836
+
837
+ if not hasattr(agent, "input_guardrails"):
838
+ agent.input_guardrails = []
839
+ agent.input_guardrails.append(traceforge_guardrail)
840
+ return agent