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,368 @@
1
+ """Recommendation rule engine — YAML-driven predicate matching."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from dataclasses import dataclass, field
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from types import MappingProxyType
11
+ from typing import TYPE_CHECKING, Literal
12
+
13
+ import yaml
14
+
15
+ from traceforge.governance.results import TransformSuggestion
16
+
17
+ if TYPE_CHECKING:
18
+ from traceforge.classify.core import Classification
19
+ from traceforge.classify.risk import RiskAssessment
20
+
21
+
22
+ class RecommendedAction(StrEnum):
23
+ ALLOW = "allow"
24
+ WARN = "warn"
25
+ ESCALATE = "escalate"
26
+ DENY = "deny"
27
+ TRANSFORM = "transform"
28
+
29
+
30
+ def _empty_parameters() -> Mapping[str, str]:
31
+ """Default immutable (read-only) parameter mapping."""
32
+ return MappingProxyType({})
33
+
34
+
35
+ _MISSING = object()
36
+
37
+
38
+ def _resolve_part(obj: object, part: str) -> object:
39
+ """Resolve a single path segment against a mapping, sequence, or object."""
40
+ if obj is None or part.startswith("_"):
41
+ return _MISSING
42
+ if isinstance(obj, Mapping):
43
+ return obj[part] if part in obj else _MISSING
44
+ if isinstance(obj, (list, tuple)):
45
+ try:
46
+ idx = int(part)
47
+ except ValueError:
48
+ return _MISSING
49
+ return obj[idx] if -len(obj) <= idx < len(obj) else _MISSING
50
+ return getattr(obj, part, _MISSING)
51
+
52
+
53
+ def resolve_field(data: object, path: str | None) -> object | None:
54
+ """Resolve a dotted ``path`` against ``data``, supporting nested fields.
55
+
56
+ Traverses mappings (by key), sequences (by integer index), and objects (by
57
+ attribute). Returns ``None`` when ``path`` is empty/None or any segment is
58
+ absent — i.e. a missing field resolves to ``None`` rather than raising.
59
+ """
60
+ if not path:
61
+ return None
62
+ current: object = data
63
+ for part in path.split("."):
64
+ current = _resolve_part(current, part)
65
+ if current is _MISSING:
66
+ return None
67
+ return current
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class TransformTemplate:
72
+ """Static template for a transform suggestion.
73
+
74
+ A transform is defined by a ``strategy`` (how to transform) plus immutable
75
+ ``parameters`` (strategy-specific config), applied to ``target_field`` (a
76
+ dotted path resolved against event data). ``pattern``/``replacement`` are
77
+ retained as the canonical inputs of the default ``pattern_replace`` strategy.
78
+ """
79
+
80
+ pattern: str | None = None
81
+ replacement: str | None = None
82
+ description: str | None = None
83
+ target_field: str | None = None
84
+ strategy: str = "pattern_replace"
85
+ parameters: Mapping[str, str] = field(default_factory=_empty_parameters)
86
+
87
+ def __post_init__(self) -> None:
88
+ # Freeze parameters into a read-only copy (frozen=True only guards rebinding).
89
+ object.__setattr__(self, "parameters", MappingProxyType(dict(self.parameters)))
90
+
91
+ def __hash__(self) -> int:
92
+ return hash(
93
+ (
94
+ self.pattern,
95
+ self.replacement,
96
+ self.description,
97
+ self.target_field,
98
+ self.strategy,
99
+ frozenset(self.parameters.items()),
100
+ )
101
+ )
102
+
103
+ def render(self, data: object) -> TransformSuggestion:
104
+ """Render this template into a concrete ``TransformSuggestion``.
105
+
106
+ Resolves ``target_field`` against ``data`` (nested; missing -> ``None``)
107
+ and preserves ``strategy``, ``parameters``, and the resolved
108
+ ``original_value`` on the produced suggestion. Always returns a suggestion;
109
+ whether to drop an unresolved transform is a downstream consumer decision.
110
+ """
111
+ original_value = resolve_field(data, self.target_field)
112
+ original_str = "" if original_value is None else str(original_value)
113
+ return TransformSuggestion(
114
+ target_kind="field",
115
+ path=self.target_field or "",
116
+ original=original_str,
117
+ replacement=self.replacement,
118
+ rationale=self.description or "Rule suggests transformation",
119
+ confidence="medium",
120
+ target_field=self.target_field,
121
+ strategy=self.strategy,
122
+ parameters=self.parameters,
123
+ original_value=original_value,
124
+ )
125
+
126
+
127
+ @dataclass(frozen=True)
128
+ class Predicate:
129
+ """Single condition in a rule's `when` clause."""
130
+
131
+ dim: str
132
+ operator: Literal["exact", "any_of", "all_of", "none_of", ">=", ">", "<=", "<", "=="]
133
+ target: str | None = None
134
+ targets: tuple[str, ...] = ()
135
+ threshold: int | None = None
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Rule:
140
+ """Single recommendation rule loaded from YAML."""
141
+
142
+ id: str
143
+ index: int
144
+ when: tuple[Predicate, ...]
145
+ recommend: RecommendedAction
146
+ reason: str | None = None
147
+ transform: TransformTemplate | None = None
148
+
149
+
150
+ @dataclass(frozen=True)
151
+ class RecommendationTemplate:
152
+ """Static output from rule matching."""
153
+
154
+ recommended_action: RecommendedAction
155
+ reason_code: str
156
+ message: str | None = None
157
+ transform: TransformTemplate | None = None
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class RuleMatch:
162
+ """Intermediate output from rule evaluation."""
163
+
164
+ template: RecommendationTemplate
165
+ rule_id: str
166
+ matched_predicates: tuple[Predicate, ...]
167
+
168
+
169
+ # Disallowed dimensions in predicates
170
+ _DISALLOWED_DIMS = frozenset({"source_labels"})
171
+ _SCALAR_DIMS = frozenset({"mechanism", "effect"})
172
+ _SET_DIMS = frozenset({"scope", "role", "action", "capability", "structure"})
173
+ _COMPARISON_RE = re.compile(r"^(>=|>|<=|<|==)\s*(\d+)$")
174
+
175
+
176
+ def parse_rules(yaml_path: str | Path) -> list[Rule]:
177
+ """Load recommendation rules from YAML file."""
178
+ path = Path(yaml_path)
179
+ with path.open() as f:
180
+ data = yaml.safe_load(f)
181
+
182
+ rules_data = data.get("recommendation_rules", [])
183
+ rules: list[Rule] = []
184
+
185
+ for i, entry in enumerate(rules_data):
186
+ when_raw = entry.get("when", {})
187
+ predicates = _parse_when(when_raw, rule_index=i)
188
+ recommend = RecommendedAction(entry["recommend"])
189
+ reason = entry.get("reason")
190
+ transform = None
191
+ if "transform" in entry:
192
+ t = entry["transform"]
193
+ if not isinstance(t, dict):
194
+ raise ValueError(f"Rule {i}: 'transform' must be a mapping")
195
+ params = t.get("parameters", {})
196
+ if not isinstance(params, dict):
197
+ raise ValueError(
198
+ f"Rule {i}: transform 'parameters' must be a mapping, "
199
+ f"got {type(params).__name__}"
200
+ )
201
+ transform = TransformTemplate(
202
+ pattern=t.get("pattern"),
203
+ replacement=t.get("replacement"),
204
+ description=t.get("description"),
205
+ target_field=t.get("target_field"),
206
+ strategy=t.get("strategy", "pattern_replace"),
207
+ parameters=params,
208
+ )
209
+ rule_id = entry.get("id", f"rule_{i}")
210
+ rules.append(
211
+ Rule(
212
+ id=rule_id,
213
+ index=i,
214
+ when=tuple(predicates),
215
+ recommend=recommend,
216
+ reason=reason,
217
+ transform=transform,
218
+ )
219
+ )
220
+
221
+ return rules
222
+
223
+
224
+ def _parse_when(when: dict, *, rule_index: int) -> list[Predicate]:
225
+ """Parse a when clause into predicates."""
226
+ _VALID_DIMS = _SCALAR_DIMS | _SET_DIMS | {"risk_score"}
227
+ predicates: list[Predicate] = []
228
+ for dim, value in when.items():
229
+ if dim in _DISALLOWED_DIMS:
230
+ raise ValueError(f"Rule {rule_index}: dimension '{dim}' is disallowed in predicates")
231
+ if dim not in _VALID_DIMS:
232
+ raise ValueError(
233
+ f"Rule {rule_index}: unknown predicate dimension '{dim}' (valid: {sorted(_VALID_DIMS)})"
234
+ )
235
+
236
+ if dim == "risk_score":
237
+ m = _COMPARISON_RE.match(str(value))
238
+ if not m:
239
+ raise ValueError(f"Rule {rule_index}: invalid risk_score predicate: {value}")
240
+ op = m.group(1)
241
+ assert op in (">=", ">", "<=", "<", "=="), f"Invalid comparison operator: {op}"
242
+ predicates.append(
243
+ Predicate(
244
+ dim="risk_score",
245
+ operator=op, # type: ignore[arg-type] # validated above
246
+ threshold=int(m.group(2)),
247
+ )
248
+ )
249
+ continue
250
+
251
+ if isinstance(value, str):
252
+ # Scalar exact match
253
+ predicates.append(Predicate(dim=dim, operator="exact", target=value))
254
+ elif isinstance(value, list):
255
+ # List = any_of shorthand
256
+ predicates.append(Predicate(dim=dim, operator="any_of", targets=tuple(value)))
257
+ elif isinstance(value, dict):
258
+ # Explicit operator dict — exactly one key
259
+ if len(value) != 1:
260
+ raise ValueError(
261
+ f"Rule {rule_index}: predicate dict must have exactly one operator key, got {list(value.keys())}"
262
+ )
263
+ op_key = next(iter(value))
264
+ if op_key not in ("any_of", "all_of", "none_of"):
265
+ raise ValueError(f"Rule {rule_index}: unknown operator '{op_key}'")
266
+ # Scalar dimensions only support any_of and none_of (not all_of — nonsensical for single value)
267
+ if dim in _SCALAR_DIMS and op_key == "all_of":
268
+ raise ValueError(
269
+ f"Rule {rule_index}: 'all_of' is not valid for scalar dimension '{dim}' (use 'exact' or 'any_of')"
270
+ )
271
+ op_targets = value[op_key]
272
+ if not isinstance(op_targets, list):
273
+ raise ValueError(
274
+ f"Rule {rule_index}: operator '{op_key}' value must be a list, got {type(op_targets).__name__}"
275
+ )
276
+ predicates.append(
277
+ Predicate(
278
+ dim=dim,
279
+ operator=op_key, # type: ignore[arg-type] # validated above
280
+ targets=tuple(op_targets),
281
+ )
282
+ )
283
+ else:
284
+ raise ValueError(
285
+ f"Rule {rule_index}: invalid predicate value type for '{dim}': {type(value)}"
286
+ )
287
+
288
+ return predicates
289
+
290
+
291
+ def evaluate_rules(
292
+ rules: list[Rule],
293
+ classification: "Classification",
294
+ risk: "RiskAssessment",
295
+ ) -> RuleMatch | None:
296
+ """Evaluate rules top-to-bottom. First match wins."""
297
+ for rule in rules:
298
+ if all(_predicate_matches(p, classification, risk) for p in rule.when):
299
+ return RuleMatch(
300
+ template=RecommendationTemplate(
301
+ recommended_action=rule.recommend,
302
+ reason_code=rule.reason or f"rule_{rule.index}",
303
+ transform=rule.transform,
304
+ ),
305
+ rule_id=rule.id,
306
+ matched_predicates=rule.when,
307
+ )
308
+ return None
309
+
310
+
311
+ def _predicate_matches(pred: Predicate, c: "Classification", r: "RiskAssessment") -> bool:
312
+ """Check if a single predicate matches the classification + risk."""
313
+ if pred.dim == "risk_score":
314
+ return _compare_score(r.score, pred.operator, pred.threshold or 0)
315
+
316
+ # Explicit dimension → value mapping (no dynamic getattr)
317
+ _DIM_VALUES: dict[str, object] = {
318
+ "mechanism": c.mechanism,
319
+ "effect": c.effect,
320
+ "scope": c.scope,
321
+ "role": c.role,
322
+ "action": c.action,
323
+ "capability": c.capability,
324
+ "structure": c.structure,
325
+ }
326
+ value = _DIM_VALUES.get(pred.dim)
327
+
328
+ # None or empty — only none_of matches vacuously
329
+ if value is None or (isinstance(value, frozenset) and len(value) == 0):
330
+ return pred.operator == "none_of"
331
+
332
+ if pred.operator == "exact":
333
+ return value == pred.target
334
+
335
+ # Set operations
336
+ if isinstance(value, frozenset):
337
+ target_set = frozenset(pred.targets)
338
+ if pred.operator == "any_of":
339
+ return bool(value & target_set)
340
+ if pred.operator == "all_of":
341
+ return target_set <= value
342
+ if pred.operator == "none_of":
343
+ return not (value & target_set)
344
+
345
+ # Scalar against any_of
346
+ if pred.operator == "any_of":
347
+ return value in pred.targets
348
+
349
+ # Scalar against none_of
350
+ if pred.operator == "none_of":
351
+ return value not in pred.targets
352
+
353
+ return False
354
+
355
+
356
+ def _compare_score(score: int, op: str, threshold: int) -> bool:
357
+ """Compare risk score against threshold."""
358
+ if op == ">=":
359
+ return score >= threshold
360
+ if op == ">":
361
+ return score > threshold
362
+ if op == "<=":
363
+ return score <= threshold
364
+ if op == "<":
365
+ return score < threshold
366
+ if op == "==":
367
+ return score == threshold
368
+ return False
@@ -0,0 +1,262 @@
1
+ """Read-only scoring / preview path for governance.
2
+
3
+ The Scorer answers "what would the pipeline conclude about this tool call?"
4
+ without mutating any persisted session state. It runs Phase 1 against a detached
5
+ clone (preflight_event), then the side-effect-free Assessor, and returns either a
6
+ SessionMeta or a unified EventTrace. It also owns the audit-only persistence of
7
+ scoring results and the fail-closed ESCALATE fallback. This is the read side;
8
+ the state-mutating write side lives in the SessionMonitor.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import uuid
15
+ from datetime import datetime, timezone
16
+ from typing import TYPE_CHECKING
17
+
18
+ from traceforge.governance.results import (
19
+ RecommendedAction,
20
+ RiskRecommendation,
21
+ SessionMeta,
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ import traceforge.types
26
+
27
+ from traceforge.governance.assessor import Assessor
28
+ from traceforge.governance.codec import MetaCodec
29
+ from traceforge.governance.context import ContextBuilder
30
+ from traceforge.governance.persistence import SystemStore
31
+ from traceforge.governance.phase1 import Phase1
32
+ from traceforge.governance.registry import SessionRegistry
33
+ from traceforge.governance.types import EnrichmentContext, ToolCallEvent
34
+ from traceforge.trace import EventTrace
35
+
36
+
37
+ class Scorer:
38
+ """Read-only governance scoring: preview assessment without persisting state."""
39
+
40
+ def __init__(
41
+ self,
42
+ context: "ContextBuilder",
43
+ phase1: "Phase1",
44
+ assessor: "Assessor",
45
+ registry: "SessionRegistry",
46
+ store: "SystemStore",
47
+ codec: "MetaCodec",
48
+ ) -> None:
49
+ self._context = context
50
+ self._phase1 = phase1
51
+ self._assessor = assessor
52
+ self._registry = registry
53
+ self._store = store
54
+ self._codec = codec
55
+
56
+ def score_tool_call(self, payload: dict) -> "EventTrace":
57
+ """Score a pending tool call against current session state.
58
+
59
+ Pure scoring — returns a fully-enriched EventTrace. Does NOT fire any callbacks.
60
+ Downstream apps decide what to do with the result.
61
+
62
+ Args:
63
+ payload: Dict with at minimum:
64
+ - ``tool_name``: str
65
+ - ``tool_input``: dict
66
+ - ``session_id``: str
67
+ Optional:
68
+ - ``server_namespace``: str
69
+ - ``project_root``: str
70
+
71
+ Returns:
72
+ EventTrace — the unified pipeline type with classification + assessment.
73
+ """
74
+ return self.score_event(payload)
75
+
76
+ def score_event(self, payload: dict) -> "EventTrace":
77
+ """Internal: build event from dict, score it, return EventTrace."""
78
+ from traceforge.governance.types import ToolCallEvent
79
+
80
+ event = ToolCallEvent.from_dict(payload)
81
+ try:
82
+ ctx = self._context.from_tool_call(event)
83
+ except Exception as exc:
84
+ meta = self._fail_closed(exc)
85
+ return self._meta_to_trace(payload, event, meta, kind="tool.call.started")
86
+
87
+ try:
88
+ meta = self.preflight_event(ctx)
89
+ except Exception as exc:
90
+ meta = self._fail_closed(exc, classification=ctx.base_classification)
91
+ return self._meta_to_trace(payload, event, meta, kind="tool.call.started")
92
+
93
+ self._persist_score(event.source_event_key, event.session_id, meta)
94
+ return self._meta_to_trace(payload, event, meta, kind="tool.call.started")
95
+
96
+ def _meta_to_trace(
97
+ self,
98
+ payload: dict,
99
+ event: "ToolCallEvent",
100
+ meta: "SessionMeta",
101
+ *,
102
+ kind: str = "tool.call.started",
103
+ ) -> "EventTrace":
104
+ """Convert internal pipeline types into a unified EventTrace."""
105
+
106
+ from traceforge.trace import EventTrace
107
+
108
+ cls = meta.classification
109
+ risk = meta.risk_assessment
110
+ rec = meta.recommendation
111
+ raw = payload if isinstance(payload, dict) else {}
112
+
113
+ return EventTrace(
114
+ id=event.event_id,
115
+ kind=kind,
116
+ session_id=event.session_id,
117
+ tool_call_id=raw.get("tool_call_id") or str(uuid.uuid4()),
118
+ timestamp=event.timestamp,
119
+ source_key=event.source_event_key,
120
+ raw_event=raw,
121
+ parent_tool_call_id=raw.get("parent_tool_call_id"),
122
+ # Tool identity (gen_ai.tool.* aligned)
123
+ tool_name=raw.get("tool_name"),
124
+ tool_input=raw.get("tool_input") or {},
125
+ tool_result=raw.get("tool_result") or raw.get("result"),
126
+ target_resource=raw.get("target_resource"),
127
+ # Classification
128
+ mechanism=cls.mechanism if cls else None,
129
+ effect=cls.effect if cls else None,
130
+ scope=tuple(cls.scope) if cls else (),
131
+ role=tuple(cls.role) if cls else (),
132
+ action=tuple(cls.action) if cls else (),
133
+ capability=tuple(cls.capability) if cls else (),
134
+ structure=tuple(cls.structure) if cls else (),
135
+ canonical_tool=raw.get("tool_name"),
136
+ # Assessment
137
+ risk_score=risk.score if risk else None,
138
+ risk_band=risk.level if risk else None,
139
+ suggested_action=rec.recommended_action.value if rec else None,
140
+ reason=rec.reason_code if rec else None,
141
+ # Stage
142
+ stage="assessed" if risk else ("classified" if cls else "adapted"),
143
+ )
144
+
145
+ def score_tool_call_event(self, event: "traceforge.types.SessionEvent") -> "SessionMeta":
146
+ """Score an enriched SessionEvent via the canonical bridge.
147
+
148
+ Same as score_tool_call but accepts a SessionEvent (from adapters/Enricher)
149
+ instead of a raw dict.
150
+
151
+ Returns:
152
+ SessionMeta — same shape sinks receive in the observation pipeline.
153
+ """
154
+ try:
155
+ ctx = self._context.from_session_event(event)
156
+ except Exception as exc:
157
+ return self._fail_closed(exc)
158
+
159
+ try:
160
+ meta = self.preflight_event(ctx)
161
+ except Exception as exc:
162
+ return self._fail_closed(exc, classification=ctx.base_classification)
163
+
164
+ self._persist_score(ctx.event.source_event_key, ctx.event.session_id, meta)
165
+ return meta
166
+
167
+ def preflight_event(self, ctx: "EnrichmentContext") -> "SessionMeta":
168
+ """Simulate full pipeline (Phase 1/2/3) without persisting state changes.
169
+
170
+ Creates a transient copy of session state, applies Phase 1 mutations
171
+ (budget, taint, drift) to it, then runs Phase 2/3 against the result.
172
+ The real state and DB are never modified.
173
+
174
+ Used by .score_tool_call() to predict what the pipeline would produce if the
175
+ event actually executed, without committing any side effects.
176
+ """
177
+ from traceforge.governance.state import SessionState
178
+
179
+ session_id = ctx.event.session_id
180
+
181
+ # Use cached state if available; otherwise start fresh (thread-safe,
182
+ # avoids cross-thread sqlite3 access for unknown sessions). Prefers the
183
+ # durable observation state so the preview reflects accumulated budgets.
184
+ state = self._registry.preview_state(session_id)
185
+ if state is None:
186
+ state = SessionState(session_id=session_id)
187
+
188
+ # Thread-safe clone — never touches state._db
189
+ transient = state.clone_detached()
190
+
191
+ # ── Phase 1 simulation (non-persisted) ──
192
+ self._phase1.apply(ctx, transient)
193
+
194
+ # ── Phase 2/3 (side-effect-free) ──
195
+ snapshot = transient.snapshot()
196
+ return self._assessor.assess(ctx, snapshot).meta
197
+
198
+ def _persist_score(self, source_event_key: str, session_id: str, meta: "SessionMeta") -> None:
199
+ """Persist a scoring result to the audit trail.
200
+
201
+ Enforces a distinct, ``score:``-namespaced source_event_key so a scoring
202
+ record never collides with the observation record for the same event. The
203
+ dict intake path already mints a ``score:`` key in
204
+ :meth:`ToolCallEvent.from_dict`; the SessionEvent path (score_tool_call_event)
205
+ carries the raw ``event.id``, so the namespace is applied here to keep both
206
+ intake channels consistent. If the same tool call later executes and arrives
207
+ via the standard pipeline, both records coexist — enabling correlation of
208
+ 'what we recommended' vs 'what actually happened'.
209
+ """
210
+ # Guarantee the score: namespace regardless of intake channel — idempotent
211
+ # for the dict path (already prefixed). Without it, a SessionEvent scored via
212
+ # score_tool_call_event would persist under its raw event id, and a later
213
+ # observation of that same event would be swallowed as a duplicate
214
+ # (is_duplicate short-circuits process_event before Phase 1 advances state).
215
+ audit_key = (
216
+ source_event_key
217
+ if source_event_key.startswith("score:")
218
+ else f"score:{source_event_key}"
219
+ )
220
+ try:
221
+ meta_dict = self._codec.serialize_meta(meta)
222
+ meta_dict["scored"] = True
223
+ meta_json = json.dumps(meta_dict)
224
+ now = datetime.now(timezone.utc).isoformat()
225
+ self._store.execute_in_transaction(
226
+ "INSERT OR IGNORE INTO processed_events (source_event_key, session_id, session_meta_json, processed_at) VALUES (?, ?, ?, ?)",
227
+ (audit_key, session_id, meta_json, now),
228
+ )
229
+ self._store.commit()
230
+ self._store.cache_processed(audit_key, meta_json)
231
+ except Exception:
232
+ # Best-effort persistence — scoring result was already returned to caller.
233
+ # If this fails, the score is still usable but won't be in the audit trail.
234
+ try:
235
+ self._store.rollback()
236
+ except Exception:
237
+ pass
238
+
239
+ def _fail_closed(self, exc: Exception, classification=None) -> "SessionMeta":
240
+ """Produce a SessionMeta that signals ESCALATE due to internal error."""
241
+ from traceforge.classify.risk import RiskAssessment
242
+
243
+ reason = f"internal_error: {type(exc).__name__}"
244
+ risk = RiskAssessment(
245
+ score=0,
246
+ level="unknown",
247
+ confidence="low",
248
+ factors=(reason,),
249
+ mitre=(),
250
+ version="1",
251
+ )
252
+ recommendation = RiskRecommendation(
253
+ recommended_action=RecommendedAction.ESCALATE,
254
+ assessment=risk,
255
+ reason_code=reason,
256
+ canonical_id="error",
257
+ )
258
+ return SessionMeta(
259
+ classification=classification,
260
+ risk_assessment=risk,
261
+ recommendation=recommendation,
262
+ )