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,441 @@
1
+ """Phase 2/3 assessment — side-effect-free labeling, risk, rules and evidence.
2
+
3
+ The :class:`Assessor` is the governance pipeline's *assessment* collaborator. Given
4
+ an :class:`EnrichmentContext` and a Phase-1 :class:`SessionStateSnapshot`, it produces
5
+ an :class:`Assessment` (a :class:`SessionMeta` plus any deferred MCP write prescriptions)
6
+ without mutating session state or touching the store. It is pure with respect to the
7
+ snapshot it is handed, which is what lets both the runtime monitor (post-commit) and the
8
+ shield's preflight simulation share exactly one assessment path.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from dataclasses import dataclass, field
14
+ from typing import TYPE_CHECKING, Protocol
15
+
16
+ from traceforge.governance.results import (
17
+ Evidence,
18
+ EvidencePointer,
19
+ EscalationContext,
20
+ Phase3Result,
21
+ RecommendationResult,
22
+ RecommendedAction,
23
+ RiskRecommendation,
24
+ SessionMeta,
25
+ TransformSuggestion,
26
+ )
27
+
28
+ if TYPE_CHECKING:
29
+ from traceforge.classify.config import ClassificationEngine
30
+ from traceforge.governance.labeler import GovernanceLabeler, GovernanceResult
31
+ from traceforge.governance.rules import Rule
32
+ from traceforge.governance.state import SessionStateSnapshot
33
+ from traceforge.governance.types import EnrichmentContext
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Assessment:
38
+ """Result of a side-effect-free Phase 2/3 assessment.
39
+
40
+ ``meta`` is the caller-facing verdict surface (classification + risk +
41
+ recommendation + evidence). ``mcp_deferred_writes`` carries the MCP fingerprint
42
+ write prescriptions emitted during labeling, and ``integrity_deferred_writes`` the
43
+ content-hash baseline prescriptions, which the writer persists *after* finalization
44
+ commits — the assessor itself never writes them.
45
+ """
46
+
47
+ meta: SessionMeta
48
+ mcp_deferred_writes: tuple = field(default_factory=tuple)
49
+ integrity_deferred_writes: tuple = field(default_factory=tuple)
50
+
51
+
52
+ class Assessor(Protocol):
53
+ """Runs side-effect-free Phase 2/3 assessment against a state snapshot."""
54
+
55
+ def assess(self, ctx: "EnrichmentContext", snapshot: "SessionStateSnapshot") -> Assessment: ...
56
+
57
+
58
+ class DefaultAssessor:
59
+ """Default assessor: Phase 2 labeling + Phase 3 risk / rules / evidence.
60
+
61
+ Depends only on the label + risk collaborators (labeler, rules, engine); it holds
62
+ no session state and performs no persistence. Swap it out (OCP) to change how
63
+ events are labeled or scored without touching the monitor or shield.
64
+ """
65
+
66
+ def __init__(
67
+ self,
68
+ labeler: "GovernanceLabeler",
69
+ rules: "list[Rule]",
70
+ engine: "ClassificationEngine",
71
+ ) -> None:
72
+ self._labeler = labeler
73
+ self._rules = rules
74
+ self._engine = engine
75
+
76
+ def assess(self, ctx: "EnrichmentContext", snapshot: "SessionStateSnapshot") -> Assessment:
77
+ """Label (Phase 2) then score/evaluate/evidence (Phase 3) against ``snapshot``."""
78
+ enrichment_ctx = self._with_snapshot(ctx, snapshot)
79
+
80
+ gov_result = self._labeler.label(enrichment_ctx)
81
+ phase3 = self._phase3(enrichment_ctx, gov_result, snapshot)
82
+
83
+ rec = None
84
+ evidence = None
85
+ if phase3.recommendation_result:
86
+ rec = phase3.recommendation_result.recommendation
87
+ evidence = phase3.recommendation_result.evidence
88
+
89
+ meta = SessionMeta(
90
+ classification=gov_result.classification,
91
+ risk_assessment=phase3.risk_assessment,
92
+ recommendation=rec,
93
+ budget_snapshot=snapshot.budget,
94
+ drift=gov_result.drift_result,
95
+ mcp_alerts=gov_result.mcp_alerts,
96
+ evidence=evidence,
97
+ )
98
+ return Assessment(
99
+ meta=meta,
100
+ mcp_deferred_writes=gov_result.mcp_deferred_writes,
101
+ integrity_deferred_writes=gov_result.integrity_deferred_writes,
102
+ )
103
+
104
+ def _with_snapshot(
105
+ self, ctx: "EnrichmentContext", snapshot: "SessionStateSnapshot"
106
+ ) -> "EnrichmentContext":
107
+ """Create new context with session state snapshot."""
108
+ import dataclasses
109
+
110
+ return dataclasses.replace(ctx, session_state=snapshot)
111
+
112
+ def _phase3(
113
+ self,
114
+ ctx: "EnrichmentContext",
115
+ result: "GovernanceResult",
116
+ snapshot: "SessionStateSnapshot" = None,
117
+ ) -> Phase3Result:
118
+ """Phase 3: risk assessment + rule evaluation + evidence + escalation context."""
119
+ from traceforge.governance.canonical import compute_canonical_hash
120
+ from traceforge.governance.risk_wrapper import assess_governance_risk
121
+ from traceforge.governance.rules import evaluate_rules
122
+
123
+ # Compute risk
124
+ risk = assess_governance_risk(
125
+ enriched_classification=result.classification,
126
+ command_analysis=ctx.command_analysis,
127
+ risk_modifiers=result.risk_modifiers,
128
+ engine=self._engine,
129
+ project_root=ctx.project_root,
130
+ )
131
+
132
+ # Evaluate rules
133
+ rule_match = evaluate_rules(self._rules, result.classification, risk)
134
+
135
+ if rule_match is None:
136
+ return Phase3Result(risk_assessment=risk, recommendation_result=None)
137
+
138
+ # Compute canonical hash
139
+ command = ctx.command_analysis.command if ctx.command_analysis else None
140
+ canonical_id = compute_canonical_hash(
141
+ result.classification,
142
+ command=command,
143
+ reason_code=rule_match.template.reason_code,
144
+ )
145
+
146
+ # Render transform suggestion (if template provided)
147
+ transform = self._render_transform(rule_match.template.transform, ctx)
148
+
149
+ # Build recommendation
150
+ recommendation = RiskRecommendation(
151
+ recommended_action=RecommendedAction(rule_match.template.recommended_action),
152
+ assessment=risk,
153
+ reason_code=rule_match.template.reason_code,
154
+ canonical_id=canonical_id,
155
+ message=rule_match.template.message,
156
+ transform=transform,
157
+ )
158
+
159
+ # Build evidence for non-allow actions
160
+ evidence = None
161
+ if recommendation.recommended_action in (
162
+ RecommendedAction.WARN,
163
+ RecommendedAction.ESCALATE,
164
+ RecommendedAction.DENY,
165
+ ):
166
+ # Build EscalationContext for escalate/deny
167
+ escalation = None
168
+ if recommendation.recommended_action in (
169
+ RecommendedAction.ESCALATE,
170
+ RecommendedAction.DENY,
171
+ ):
172
+ escalation = self._build_escalation(
173
+ ctx,
174
+ result,
175
+ risk,
176
+ recommendation,
177
+ canonical_id,
178
+ snapshot,
179
+ )
180
+
181
+ evidence = Evidence(
182
+ canonical_id=canonical_id,
183
+ timestamp=ctx.event.timestamp,
184
+ session_id=ctx.event.session_id,
185
+ mechanism=result.classification.mechanism,
186
+ effect=result.classification.effect,
187
+ scope=tuple(sorted(result.classification.scope)),
188
+ role=tuple(sorted(result.classification.role)),
189
+ action=tuple(sorted(result.classification.action)),
190
+ capability=tuple(sorted(result.classification.capability)),
191
+ structure=tuple(sorted(result.classification.structure)),
192
+ source_labels=tuple(sorted(result.classification.source_labels)),
193
+ recommended_action=recommendation.recommended_action,
194
+ risk_score=risk.score,
195
+ risk_factors=risk.factors,
196
+ mitre_techniques=risk.mitre,
197
+ rule_id=rule_match.rule_id,
198
+ matched_predicates=self._serialize_predicates(rule_match.matched_predicates),
199
+ pointers=(
200
+ EvidencePointer(
201
+ event_id=ctx.event.event_id,
202
+ rule_id=rule_match.rule_id,
203
+ detector="rule_engine",
204
+ payload_pointer=self._serialize_triggering_values(
205
+ result.classification, risk, rule_match.matched_predicates
206
+ ),
207
+ ),
208
+ ),
209
+ escalation=escalation,
210
+ )
211
+
212
+ return Phase3Result(
213
+ risk_assessment=risk,
214
+ recommendation_result=RecommendationResult(
215
+ recommendation=recommendation,
216
+ evidence=evidence,
217
+ ),
218
+ )
219
+
220
+ def _render_transform(self, template, ctx: "EnrichmentContext") -> TransformSuggestion | None:
221
+ """Render TransformTemplate → TransformSuggestion using event data.
222
+
223
+ Field-style templates (``target_field`` set) delegate to the template's own
224
+ renderer, which resolves the target against the event's argument/result JSON
225
+ (nested; missing → ``None``) and preserves the ``strategy``, immutable
226
+ ``parameters``, and resolved ``original_value``. Legacy pattern/replacement
227
+ templates (``target_field`` unset) keep their shell/tool-arg heuristics and are
228
+ dropped when the target cannot be located.
229
+ """
230
+ if template is None:
231
+ return None
232
+
233
+ import logging
234
+
235
+ from traceforge.governance.types import ToolCallEvent
236
+
237
+ # Field-style transform: resolve target_field against structured event data.
238
+ # render()/resolve_field never raise (data=None → original_value None).
239
+ if template.target_field is not None:
240
+ return template.render(self._render_data_for(ctx.event))
241
+
242
+ try:
243
+ if isinstance(ctx.event, ToolCallEvent) and ctx.command_analysis:
244
+ original = ctx.command_analysis.command or ""
245
+ replacement = template.replacement if template.replacement is not None else None
246
+ return TransformSuggestion(
247
+ target_kind="shell_arg",
248
+ path=f"command[0:{len(original)}]",
249
+ original=original,
250
+ replacement=replacement,
251
+ rationale=template.description or "Rule suggests transformation",
252
+ confidence="medium",
253
+ )
254
+ elif isinstance(ctx.event, ToolCallEvent):
255
+ args_str = ctx.event.tool_args_json
256
+ return TransformSuggestion(
257
+ target_kind="tool_arg",
258
+ path="$.args",
259
+ original=args_str[:200],
260
+ replacement=None,
261
+ rationale=template.description or "Rule suggests transformation",
262
+ confidence="low",
263
+ )
264
+ except (KeyError, AttributeError, TypeError) as e:
265
+ logging.getLogger(__name__).debug(
266
+ "Transform rendering failed for template %s: %s", template, e
267
+ )
268
+ return None
269
+
270
+ def _render_data_for(self, event) -> object | None:
271
+ """Best-effort structured payload for ``TransformTemplate.render()``; None if unavailable.
272
+
273
+ Parses a :class:`ToolCallEvent`'s ``tool_args_json`` or a :class:`ToolResultEvent`'s
274
+ ``result_payload_json``. Absent or unparseable JSON yields ``None`` so the caller's
275
+ ``render()`` resolves ``original_value`` to ``None`` rather than raising.
276
+ """
277
+ import json
278
+
279
+ from traceforge.governance.types import ToolCallEvent, ToolResultEvent
280
+
281
+ raw = None
282
+ if isinstance(event, ToolCallEvent):
283
+ raw = event.tool_args_json
284
+ elif isinstance(event, ToolResultEvent):
285
+ raw = event.result_payload_json
286
+ if raw is None:
287
+ return None
288
+ try:
289
+ return json.loads(raw)
290
+ except (ValueError, TypeError):
291
+ return None
292
+
293
+ def _build_escalation(
294
+ self,
295
+ ctx: "EnrichmentContext",
296
+ result: "GovernanceResult",
297
+ risk,
298
+ recommendation,
299
+ canonical_id: str,
300
+ snapshot,
301
+ ) -> EscalationContext:
302
+ """Build full EscalationContext for escalate/deny recommendations."""
303
+ from traceforge.governance.types import ToolCallEvent
304
+
305
+ tool_name = ""
306
+ tool_args_summary = ""
307
+ if isinstance(ctx.event, ToolCallEvent):
308
+ tool_name = ctx.event.tool_name or ""
309
+ # Sanitize args — truncate and remove obvious secrets
310
+ raw_args = ctx.event.tool_args_json or ""
311
+ tool_args_summary = self._sanitize_args(raw_args)
312
+
313
+ pii_taint = (
314
+ "pii_exposure" in result.classification.capability
315
+ or "credential_exposure" in result.classification.capability
316
+ )
317
+ ifc_violations = result.risk_modifiers.ifc_violations
318
+
319
+ budget = snapshot.budget if snapshot else None
320
+
321
+ return EscalationContext(
322
+ canonical_id=canonical_id,
323
+ classification=result.classification,
324
+ recommended_action=recommendation.recommended_action,
325
+ reason_code=recommendation.reason_code,
326
+ mitre_techniques=risk.mitre,
327
+ drift=result.drift_result,
328
+ budget_snapshot=budget,
329
+ pii_taint=pii_taint,
330
+ ifc_violations=ifc_violations,
331
+ tool_name=tool_name,
332
+ tool_args_summary=tool_args_summary,
333
+ session_id=ctx.event.session_id,
334
+ timestamp=ctx.event.timestamp,
335
+ event_id=ctx.event.event_id,
336
+ classification_summary=self._summarize_classification(result.classification),
337
+ risk_factors=risk.factors,
338
+ session_event_count=snapshot.event_count if snapshot else 0,
339
+ recent_phase_window=snapshot.phase_window if snapshot else (),
340
+ )
341
+
342
+ def _sanitize_args(self, raw_args: str, max_len: int = 500) -> str:
343
+ """Sanitize tool args for escalation context — remove secrets, truncate."""
344
+ import re
345
+
346
+ # Word-boundary anchored to avoid matching "monkey", "turkey", "keyboard" etc.
347
+ _SENSITIVE_KEYS = r"(?<![a-zA-Z])(?:password|secret|token|api_key|credential|auth|authorization)(?![a-zA-Z])"
348
+ # Handle JSON-style: "key": "value" or "key":"value"
349
+ sanitized = re.sub(
350
+ r'(?i)(["\']?(?:' + _SENSITIVE_KEYS + r')["\']?\s*[:=]\s*)["\']([^"\']*)["\']',
351
+ r'\1"<REDACTED>"',
352
+ raw_args,
353
+ )
354
+ # Handle bare (non-quoted) values: key=value or key: value (no quotes around value)
355
+ sanitized = re.sub(
356
+ r"(?i)((?:" + _SENSITIVE_KEYS + r')\s*[:=]\s*)([^\s"\'}{,\]\)]+)',
357
+ r"\1<REDACTED>",
358
+ sanitized,
359
+ )
360
+ if len(sanitized) > max_len:
361
+ sanitized = sanitized[:max_len] + "..."
362
+ return sanitized
363
+
364
+ # ── #24 / #25 serialization helpers ──
365
+
366
+ @staticmethod
367
+ def _summarize_classification(classification) -> str:
368
+ """Concise, deterministic human-readable summary of a classification.
369
+
370
+ Renders ``mechanism[/effect]`` followed by the salient set dimensions
371
+ (capability, action, scope) sorted for stability. Empty dimensions are
372
+ omitted; a ``None`` classification yields an empty string.
373
+ """
374
+ if classification is None:
375
+ return ""
376
+ head = classification.mechanism or "unknown"
377
+ if classification.effect:
378
+ head = f"{head}/{classification.effect}"
379
+ parts: list[str] = []
380
+ if classification.capability:
381
+ parts.append("caps=" + ",".join(sorted(classification.capability)))
382
+ if classification.action:
383
+ parts.append("actions=" + ",".join(sorted(classification.action)))
384
+ if classification.scope:
385
+ parts.append("scope=" + ",".join(sorted(classification.scope)))
386
+ return f"{head} ({'; '.join(parts)})" if parts else head
387
+
388
+ @staticmethod
389
+ def _format_predicate(pred) -> str:
390
+ """Render one matched rule predicate as a compact, deterministic string."""
391
+ if pred.dim == "risk_score":
392
+ threshold = pred.threshold if pred.threshold is not None else 0
393
+ return f"risk_score {pred.operator} {threshold}"
394
+ if pred.operator == "exact":
395
+ return f"{pred.dim} == {pred.target}"
396
+ return f"{pred.dim} {pred.operator} [{','.join(sorted(pred.targets))}]"
397
+
398
+ def _serialize_predicates(self, predicates) -> tuple[str, ...]:
399
+ """Serialize the rule predicates that matched this event (rule requirements)."""
400
+ return tuple(self._format_predicate(p) for p in predicates)
401
+
402
+ @staticmethod
403
+ def _classification_dim_value(classification, dim: str) -> str | None:
404
+ """Concrete value of ``dim`` on ``classification`` (None when absent/empty)."""
405
+ if classification is None:
406
+ return None
407
+ if dim == "mechanism":
408
+ return classification.mechanism or None
409
+ if dim == "effect":
410
+ return classification.effect
411
+ set_value = {
412
+ "scope": classification.scope,
413
+ "role": classification.role,
414
+ "action": classification.action,
415
+ "capability": classification.capability,
416
+ "structure": classification.structure,
417
+ }.get(dim)
418
+ if set_value:
419
+ return "[" + ",".join(sorted(set_value)) + "]"
420
+ return None
421
+
422
+ def _serialize_triggering_values(self, classification, risk, predicates) -> str:
423
+ """Compact, sanitized serialization of the values that triggered the match.
424
+
425
+ For each matched predicate we record the *actual* value the rule engine
426
+ evaluated — the classification dimension value, or the concrete risk score
427
+ for ``risk_score`` predicates. Keys are sorted for determinism and the
428
+ rendered string is passed through :meth:`_sanitize_args` so the same
429
+ secret-redaction discipline applies, even though classification labels are
430
+ derived tokens rather than raw event payloads.
431
+ """
432
+ values: dict[str, str] = {}
433
+ for pred in predicates:
434
+ if pred.dim == "risk_score":
435
+ values["risk_score"] = str(risk.score)
436
+ continue
437
+ actual = self._classification_dim_value(classification, pred.dim)
438
+ if actual is not None:
439
+ values[pred.dim] = actual
440
+ rendered = "; ".join(f"{dim}={values[dim]}" for dim in sorted(values))
441
+ return self._sanitize_args(rendered)
@@ -0,0 +1,59 @@
1
+ """Budget tracking and pressure detection for governance enrichment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from traceforge.governance.state import SessionState
10
+ from traceforge.governance.types import EnrichmentContext
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BudgetThresholds:
15
+ """Configurable budget thresholds. Pressure label applied when ANY is exceeded."""
16
+
17
+ max_tool_calls: int | None = None
18
+ max_by_effect: dict[str, int] | None = None # e.g. {"destructive": 5}
19
+ max_by_capability: dict[str, int] | None = None
20
+ max_by_scope: dict[str, int] | None = None
21
+
22
+
23
+ class BudgetTracker:
24
+ """Handles budget increment logic and pressure detection."""
25
+
26
+ def __init__(self, thresholds: BudgetThresholds | None = None) -> None:
27
+ self._thresholds = thresholds
28
+
29
+ def increment(self, ctx: "EnrichmentContext", state: "SessionState") -> None:
30
+ """Increment budget counters for the current event's classification."""
31
+ cls = ctx.base_classification
32
+ # Infer current phase from the session's phase window
33
+ phase_window = state.snapshot().phase_window
34
+ phase = phase_window[-1] if phase_window else None
35
+ state.increment_budget(
36
+ mechanism=cls.mechanism,
37
+ effect=cls.effect,
38
+ scope=cls.scope,
39
+ role=cls.role,
40
+ action=cls.action,
41
+ capability=cls.capability,
42
+ structure=cls.structure,
43
+ phase=phase,
44
+ )
45
+
46
+ def check_pressure(self, state: "SessionState") -> bool:
47
+ """Check if budget pressure threshold is exceeded."""
48
+ if not self._thresholds:
49
+ return False
50
+ thresholds_dict: dict = {}
51
+ if self._thresholds.max_tool_calls:
52
+ thresholds_dict["max_tool_calls"] = self._thresholds.max_tool_calls
53
+ if self._thresholds.max_by_effect:
54
+ thresholds_dict["max_by_effect"] = self._thresholds.max_by_effect
55
+ if self._thresholds.max_by_capability:
56
+ thresholds_dict["max_by_capability"] = self._thresholds.max_by_capability
57
+ if self._thresholds.max_by_scope:
58
+ thresholds_dict["max_by_scope"] = self._thresholds.max_by_scope
59
+ return state.check_pressure(thresholds_dict)
@@ -0,0 +1,56 @@
1
+ """Canonical action identity computation via SHA-256."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from traceforge.classify.core import Classification
11
+
12
+
13
+ _CANONICAL_VERSION = "v1"
14
+
15
+ # Session-contextual labels excluded from canonical hash (runtime-dependent)
16
+ _DYNAMIC_CAPABILITIES = frozenset({"budget_pressure", "mcp_drift"})
17
+ _DYNAMIC_STRUCTURES = frozenset({"phase_anomaly", "semantic_drift", "ifc_violation"})
18
+
19
+
20
+ def compute_canonical_hash(
21
+ classification: "Classification",
22
+ command: str | None = None,
23
+ reason_code: str | None = None,
24
+ ) -> str:
25
+ """Compute canonical action identity hash.
26
+
27
+ Includes: mechanism, effect, scope, role, action, stable capability/structure, command, reason_code.
28
+ Excludes: source_labels, budget_pressure, phase_anomaly, semantic_drift (dynamic labels).
29
+ Command is normalized (whitespace-collapsed) so formatting doesn't affect hash.
30
+ """
31
+ # Stable capability = capability minus dynamic labels
32
+ stable_cap = sorted(classification.capability - _DYNAMIC_CAPABILITIES)
33
+
34
+ # Stable structure = structure minus dynamic labels
35
+ stable_struct = sorted(classification.structure - _DYNAMIC_STRUCTURES)
36
+
37
+ payload: dict = {
38
+ "v": _CANONICAL_VERSION,
39
+ "mechanism": classification.mechanism,
40
+ "effect": classification.effect,
41
+ "scope": sorted(classification.scope),
42
+ "role": sorted(classification.role),
43
+ "action": sorted(classification.action),
44
+ "capability": stable_cap,
45
+ "structure": stable_struct,
46
+ }
47
+
48
+ # Normalize command: strip + collapse internal whitespace
49
+ if command:
50
+ payload["command"] = " ".join(command.split())
51
+ if reason_code:
52
+ payload["reason"] = reason_code
53
+
54
+ serialized = json.dumps(payload, sort_keys=True, separators=(",", ":"))
55
+ digest = hashlib.sha256(serialized.encode()).hexdigest()
56
+ return f"sha256:{digest}"