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,554 @@
1
+ """Pluggable YAML-based configuration for the classification system.
2
+
3
+ Provides Pydantic models for validation, a discovery-based loader, and a
4
+ ClassificationEngine that materializes config into runtime data structures.
5
+
6
+ Config discovery order (highest priority first):
7
+ 1. Explicit ``config_path`` passed to ``load_config()``
8
+ 2. ``TRACEFORGE_CONFIG`` environment variable
9
+ 3. ``.traceforge/config.yaml`` in project directory (cwd upward)
10
+ 4. ``~/.config/traceforge/config.yaml`` (user-global)
11
+ 5. Entry points: ``traceforge.profiles`` group
12
+ 6. Built-in defaults (from ``classify/data/*.yaml``)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import importlib.metadata
18
+ import importlib.resources
19
+ import logging
20
+ import os
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import yaml
25
+ from pydantic import Field
26
+
27
+ from traceforge.classify.core import Classification
28
+ from traceforge.classify.phases import with_phase_map
29
+ from traceforge.models import StrictModel
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ # ── Pydantic config models ──
35
+
36
+
37
+ class McpToolOverrideConfig(StrictModel):
38
+ """Per-tool classification override within an MCP server profile."""
39
+
40
+ effect: str | None = None
41
+ mechanism: str | None = None
42
+ role: list[str] | None = None
43
+ action: list[str] | None = None
44
+ scope: list[str] | None = None
45
+ capability: list[str] | None = None
46
+
47
+
48
+ class McpProfileConfig(StrictModel):
49
+ """Classification profile for a known MCP server."""
50
+
51
+ id: str | None = None
52
+ disabled: bool = False
53
+ namespace_aliases: list[str]
54
+ mechanism: str
55
+ role: list[str] = Field(default_factory=list)
56
+ default_effect: str | None = None
57
+ scope: list[str] = Field(default_factory=list)
58
+ action: list[str] = Field(default_factory=list)
59
+ capability: list[str] = Field(default_factory=list)
60
+ tool_overrides: dict[str, McpToolOverrideConfig] = Field(default_factory=dict)
61
+
62
+
63
+ class ShellRuleConfig(StrictModel):
64
+ """Declarative classification rule for shell commands."""
65
+
66
+ id: str | None = None
67
+ disabled: bool = False
68
+ binaries: list[str]
69
+ activity: str
70
+ subcmds: list[str] | None = None
71
+ flags_require: list[str] | None = None
72
+ flags_reject: list[str] | None = None
73
+ role: str = ""
74
+ effect: str = ""
75
+ scope: str = ""
76
+ action: str = ""
77
+ phase: str = ""
78
+
79
+
80
+ class BinaryInfoConfig(StrictModel):
81
+ """Static metadata about a known binary."""
82
+
83
+ role: str
84
+ default_effect: str | None = None
85
+ network: bool = False
86
+ destructive: bool = False
87
+
88
+
89
+ class ToolClassificationConfig(StrictModel):
90
+ """Classification for a known native tool."""
91
+
92
+ mechanism: str
93
+ effect: str | None = None
94
+ scope: list[str] = Field(default_factory=list)
95
+ role: list[str] = Field(default_factory=list)
96
+ action: list[str] = Field(default_factory=list)
97
+ capability: list[str] = Field(default_factory=list)
98
+
99
+
100
+ class VerbInferenceEntry(StrictModel):
101
+ """Verb prefix → (effect, action) mapping."""
102
+
103
+ effect: str
104
+ action: str
105
+
106
+
107
+ class FlagEffectConfig(StrictModel):
108
+ """Flag-based effect override."""
109
+
110
+ flags: list[str]
111
+ effect: str
112
+ mode: str = "any_present"
113
+
114
+
115
+ class EffectOverrideConfig(StrictModel):
116
+ """Effect override rules for a specific binary."""
117
+
118
+ flag_effects: list[FlagEffectConfig] = Field(default_factory=list)
119
+ subcmd_effects: dict[str, str] = Field(default_factory=dict)
120
+ compound_subcmd_effects: dict[str, dict[str, str]] = Field(default_factory=dict)
121
+ verb_prefix_effects: dict[str, str] = Field(default_factory=dict)
122
+ default_effect: str | None = None
123
+
124
+
125
+ class ClassifyConfig(StrictModel):
126
+ """Top-level config for all externalized classification data.
127
+
128
+ Users supply partial configs — only the sections they want to override.
129
+ The loader merges user config on top of built-in defaults.
130
+ """
131
+
132
+ canonical_tools: dict[str, str] = Field(default_factory=dict)
133
+ mcp_profiles: list[McpProfileConfig] = Field(default_factory=list)
134
+ shell_rules: list[ShellRuleConfig] = Field(default_factory=list)
135
+ binary_info: dict[str, BinaryInfoConfig] = Field(default_factory=dict)
136
+ tool_classifications: dict[str, ToolClassificationConfig] = Field(default_factory=dict)
137
+ verb_inference: dict[str, VerbInferenceEntry] = Field(default_factory=dict)
138
+ effect_overrides: dict[str, EffectOverrideConfig] = Field(default_factory=dict)
139
+ npm_verify_scripts: list[str] = Field(default_factory=list)
140
+ interpreter_verify_modules: list[str] = Field(default_factory=list)
141
+ git_subcmd_actions: dict[str, str] = Field(default_factory=dict)
142
+ verification_role_actions: dict[str, str] = Field(default_factory=dict)
143
+ transparent_wrappers: list[str] = Field(default_factory=list)
144
+ activity_defaults: dict[str, dict[str, str]] = Field(default_factory=dict)
145
+
146
+ @classmethod
147
+ def from_yaml(cls, path: str | Path) -> ClassifyConfig:
148
+ """Load and validate a single YAML file."""
149
+ with open(path, encoding="utf-8") as f:
150
+ data = yaml.safe_load(f) or {}
151
+ return cls.model_validate(data)
152
+
153
+ @classmethod
154
+ def from_dict(cls, data: dict[str, Any]) -> ClassifyConfig:
155
+ """Validate a raw dict (e.g., from entry point)."""
156
+ return cls.model_validate(data)
157
+
158
+
159
+ # ── Config loading and discovery ──
160
+
161
+
162
+ def _find_project_config() -> Path | None:
163
+ """Walk cwd upward looking for .traceforge/config.yaml."""
164
+ cwd = Path.cwd()
165
+ for parent in [cwd, *cwd.parents]:
166
+ candidate = parent / ".traceforge" / "config.yaml"
167
+ if candidate.is_file():
168
+ return candidate
169
+ # Also check config.yml
170
+ candidate_yml = parent / ".traceforge" / "config.yml"
171
+ if candidate_yml.is_file():
172
+ return candidate_yml
173
+ return None
174
+
175
+
176
+ def _find_user_config() -> Path | None:
177
+ """Find user-global config at ~/.config/traceforge/config.yaml."""
178
+ config_home = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
179
+ for ext in ("yaml", "yml"):
180
+ candidate = config_home / "traceforge" / f"config.{ext}"
181
+ if candidate.is_file():
182
+ return candidate
183
+ return None
184
+
185
+
186
+ def _load_entry_point_configs() -> list[dict[str, Any]]:
187
+ """Load configs from installed packages via entry points."""
188
+ configs: list[dict[str, Any]] = []
189
+ try:
190
+ eps = importlib.metadata.entry_points(group="traceforge.profiles")
191
+ except TypeError:
192
+ # Python 3.11 compatibility
193
+ eps = importlib.metadata.entry_points().get("traceforge.profiles", [])
194
+
195
+ for ep in sorted(eps, key=lambda e: e.name):
196
+ try:
197
+ factory = ep.load()
198
+ result = factory()
199
+ if isinstance(result, dict):
200
+ configs.append(result)
201
+ elif isinstance(result, (str, Path)):
202
+ with open(result, encoding="utf-8") as f:
203
+ data = yaml.safe_load(f) or {}
204
+ configs.append(data)
205
+ else:
206
+ logger.warning(
207
+ "Entry point %s returned unsupported type %s; expected dict or path",
208
+ ep.name,
209
+ type(result).__name__,
210
+ )
211
+ except Exception:
212
+ logger.warning("Failed to load entry point %s", ep.name, exc_info=True)
213
+ return configs
214
+
215
+
216
+ def _load_builtin_defaults() -> dict[str, Any]:
217
+ """Load built-in YAML defaults from the package data directory."""
218
+ merged: dict[str, Any] = {}
219
+ data_dir = importlib.resources.files("traceforge.classify") / "data"
220
+ for filename in (
221
+ "canonical_tools.yaml",
222
+ "verb_inference.yaml",
223
+ "binary_info.yaml",
224
+ "shell_defaults.yaml",
225
+ "effect_overrides.yaml",
226
+ "mcp_profiles.yaml",
227
+ "shell_rules.yaml",
228
+ "tool_classifications.yaml",
229
+ ):
230
+ resource = data_dir / filename
231
+ try:
232
+ text = resource.read_text(encoding="utf-8")
233
+ data = yaml.safe_load(text) or {}
234
+ merged = _merge_raw(merged, data)
235
+ except FileNotFoundError:
236
+ logger.debug("Built-in config file %s not found (optional)", filename)
237
+ return merged
238
+
239
+
240
+ def _merge_raw(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
241
+ """Merge two raw config dicts.
242
+
243
+ Merge semantics:
244
+ - Dict fields (canonical_tools, binary_info, etc.): override extends/replaces per key
245
+ - List fields (mcp_profiles, shell_rules): override prepends (higher priority first)
246
+ - Scalar fields: override replaces
247
+ """
248
+ result = dict(base)
249
+ for key, value in override.items():
250
+ if key not in result:
251
+ result[key] = value
252
+ elif isinstance(value, dict) and isinstance(result[key], dict):
253
+ result[key] = {**result[key], **value}
254
+ elif isinstance(value, list) and isinstance(result[key], list):
255
+ # Prepend: user rules come first (higher priority)
256
+ result[key] = value + result[key]
257
+ else:
258
+ result[key] = value
259
+ return result
260
+
261
+
262
+ def _apply_disabled_entries(config: ClassifyConfig) -> ClassifyConfig:
263
+ """Remove entries marked with ``disabled: true``."""
264
+ active_profiles = [p for p in config.mcp_profiles if not p.disabled]
265
+ active_rules = [r for r in config.shell_rules if not r.disabled]
266
+
267
+ # Handle ID-based replacement: if two entries share an ID, keep the first (higher priority)
268
+ seen_profile_ids: set[str] = set()
269
+ deduped_profiles: list[McpProfileConfig] = []
270
+ for p in active_profiles:
271
+ if p.id:
272
+ if p.id in seen_profile_ids:
273
+ continue
274
+ seen_profile_ids.add(p.id)
275
+ deduped_profiles.append(p)
276
+
277
+ seen_rule_ids: set[str] = set()
278
+ deduped_rules: list[ShellRuleConfig] = []
279
+ for r in active_rules:
280
+ if r.id:
281
+ if r.id in seen_rule_ids:
282
+ continue
283
+ seen_rule_ids.add(r.id)
284
+ deduped_rules.append(r)
285
+
286
+ return config.model_copy(
287
+ update={
288
+ "mcp_profiles": deduped_profiles,
289
+ "shell_rules": deduped_rules,
290
+ }
291
+ )
292
+
293
+
294
+ def load_config(
295
+ config_path: str | Path | None = None,
296
+ *,
297
+ merge_defaults: bool = True,
298
+ ) -> ClassifyConfig:
299
+ """Load classification config with full discovery chain.
300
+
301
+ Args:
302
+ config_path: Explicit path to a YAML config file (highest priority).
303
+ merge_defaults: Whether to merge with built-in defaults (default True).
304
+ Set False to use ONLY the provided config.
305
+
306
+ Returns:
307
+ Validated ClassifyConfig with all layers merged.
308
+ """
309
+ layers: list[dict[str, Any]] = []
310
+
311
+ # Layer 6: Built-in defaults (lowest priority)
312
+ if merge_defaults:
313
+ layers.append(_load_builtin_defaults())
314
+
315
+ # Layer 5: Entry points
316
+ for ep_config in _load_entry_point_configs():
317
+ layers.append(ep_config)
318
+
319
+ # Layer 4: User-global config
320
+ user_config = _find_user_config()
321
+ if user_config:
322
+ logger.debug("Loading user config from %s", user_config)
323
+ with open(user_config, encoding="utf-8") as f:
324
+ data = yaml.safe_load(f) or {}
325
+ layers.append(data)
326
+
327
+ # Layer 3: Project-local config
328
+ project_config = _find_project_config()
329
+ if project_config:
330
+ logger.debug("Loading project config from %s", project_config)
331
+ with open(project_config, encoding="utf-8") as f:
332
+ data = yaml.safe_load(f) or {}
333
+ layers.append(data)
334
+
335
+ # Layer 2: Environment variable
336
+ env_path = os.environ.get("TRACEFORGE_CONFIG")
337
+ if env_path and Path(env_path).is_file():
338
+ logger.debug("Loading config from TRACEFORGE_CONFIG=%s", env_path)
339
+ with open(env_path, encoding="utf-8") as f:
340
+ data = yaml.safe_load(f) or {}
341
+ layers.append(data)
342
+
343
+ # Layer 1: Explicit config_path (highest priority)
344
+ if config_path is not None:
345
+ logger.debug("Loading explicit config from %s", config_path)
346
+ with open(config_path, encoding="utf-8") as f:
347
+ data = yaml.safe_load(f) or {}
348
+ layers.append(data)
349
+
350
+ # Merge all layers (rightmost = highest priority)
351
+ merged: dict[str, Any] = {}
352
+ for layer in layers:
353
+ merged = _merge_raw(merged, layer)
354
+
355
+ # Filter to only ClassifyConfig fields (shared config files may contain
356
+ # top-level keys for other traceforge subsystems like log_level, sdk, etc.)
357
+ valid_keys = set(ClassifyConfig.model_fields.keys())
358
+ filtered = {k: v for k, v in merged.items() if k in valid_keys}
359
+
360
+ config = ClassifyConfig.model_validate(filtered)
361
+ config = _apply_disabled_entries(config)
362
+ return config
363
+
364
+
365
+ # ── ClassificationEngine — materialized runtime data ──
366
+
367
+
368
+ def _load_risk_config() -> dict[str, Any] | None:
369
+ """Load risk.yaml from built-in data directory."""
370
+ data_dir = importlib.resources.files("traceforge.classify") / "data"
371
+ resource = data_dir / "risk.yaml"
372
+ try:
373
+ text = resource.read_text(encoding="utf-8")
374
+ return yaml.safe_load(text) or None
375
+ except FileNotFoundError:
376
+ logger.debug("risk.yaml not found — risk scoring disabled")
377
+ return None
378
+
379
+
380
+ class ClassificationEngine:
381
+ """Immutable pre-built classification indexes from config.
382
+
383
+ Constructed once from a ClassifyConfig, then used by classify_tool(),
384
+ classify_mcp_tool(), match_rule(), etc. Enricher owns one of these.
385
+ """
386
+
387
+ __slots__ = (
388
+ "canonical_tools",
389
+ "tool_classifications",
390
+ "verb_inference",
391
+ "effect_overrides",
392
+ "shell_rules",
393
+ "rules_by_binary",
394
+ "binary_info",
395
+ "mcp_profiles",
396
+ "mcp_alias_index",
397
+ "npm_verify_scripts",
398
+ "interpreter_verify_modules",
399
+ "git_subcmd_actions",
400
+ "verification_role_actions",
401
+ "transparent_wrappers",
402
+ "activity_to_action",
403
+ "activity_to_scope",
404
+ "activity_to_phase",
405
+ "risk_config",
406
+ )
407
+
408
+ def __init__(self, config: ClassifyConfig) -> None:
409
+ from traceforge.classify.mcp import McpServerProfile, McpToolOverride
410
+ from traceforge.classify.rules import BinaryInfo, Rule, ShellActivity
411
+
412
+ # Canonical tool aliases — normalize all keys to lowercase for lookup
413
+ self.canonical_tools: dict[str, str] = {
414
+ k.lower().replace("-", "_"): v for k, v in config.canonical_tools.items()
415
+ }
416
+
417
+ # Tool classifications (with phase_map derived)
418
+ self.tool_classifications: dict[str, Classification] = {}
419
+ for name, tc in config.tool_classifications.items():
420
+ cls = Classification(
421
+ mechanism=tc.mechanism,
422
+ effect=tc.effect,
423
+ scope=frozenset(tc.scope),
424
+ role=frozenset(tc.role),
425
+ action=frozenset(tc.action),
426
+ capability=frozenset(tc.capability),
427
+ )
428
+ self.tool_classifications[name] = with_phase_map(cls)
429
+
430
+ # Verb inference
431
+ self.verb_inference: dict[str, tuple[str, str]] = {
432
+ verb: (entry.effect, entry.action) for verb, entry in config.verb_inference.items()
433
+ }
434
+
435
+ # Effect overrides (flag/subcmd-based effect determination)
436
+ self.effect_overrides: dict[str, EffectOverrideConfig] = dict(config.effect_overrides)
437
+
438
+ # Shell rules
439
+ self.shell_rules: tuple[Rule, ...] = tuple(
440
+ Rule(
441
+ binaries=frozenset(r.binaries),
442
+ activity=ShellActivity(r.activity),
443
+ subcmds=frozenset(r.subcmds) if r.subcmds is not None else None,
444
+ flags_require=frozenset(r.flags_require) if r.flags_require is not None else None,
445
+ flags_reject=frozenset(r.flags_reject) if r.flags_reject is not None else None,
446
+ role=r.role,
447
+ effect=r.effect,
448
+ scope=r.scope,
449
+ action=r.action,
450
+ phase=r.phase,
451
+ )
452
+ for r in config.shell_rules
453
+ )
454
+
455
+ # Pre-index rules by binary name for O(1) lookup in match_rule
456
+ _rules_by_binary: dict[str, list[Rule]] = {}
457
+ for rule in self.shell_rules:
458
+ for binary_name in rule.binaries:
459
+ _rules_by_binary.setdefault(binary_name, []).append(rule)
460
+ self.rules_by_binary: dict[str, tuple[Rule, ...]] = {
461
+ k: tuple(v) for k, v in _rules_by_binary.items()
462
+ }
463
+
464
+ # Binary info
465
+ from traceforge.classify.core import Effect as EffectEnum
466
+
467
+ self.binary_info: dict[str, BinaryInfo] = {
468
+ name: BinaryInfo(
469
+ role=bi.role,
470
+ default_effect=EffectEnum(bi.default_effect) if bi.default_effect else None,
471
+ network=bi.network,
472
+ destructive=bi.destructive,
473
+ )
474
+ for name, bi in config.binary_info.items()
475
+ }
476
+
477
+ # MCP profiles
478
+ profiles: list[McpServerProfile] = []
479
+ for pc in config.mcp_profiles:
480
+ overrides: dict[str, McpToolOverride] = {}
481
+ for tool_name, oc in pc.tool_overrides.items():
482
+ overrides[tool_name] = McpToolOverride(
483
+ effect=oc.effect,
484
+ mechanism=oc.mechanism,
485
+ role=frozenset(oc.role) if oc.role is not None else None,
486
+ action=frozenset(oc.action) if oc.action is not None else None,
487
+ scope=frozenset(oc.scope) if oc.scope is not None else None,
488
+ capability=frozenset(oc.capability) if oc.capability is not None else None,
489
+ )
490
+ profiles.append(
491
+ McpServerProfile(
492
+ namespace_aliases=tuple(pc.namespace_aliases),
493
+ mechanism=pc.mechanism,
494
+ role=frozenset(pc.role),
495
+ default_effect=pc.default_effect,
496
+ scope=frozenset(pc.scope),
497
+ action=frozenset(pc.action),
498
+ capability=frozenset(pc.capability),
499
+ tool_overrides=overrides,
500
+ )
501
+ )
502
+ self.mcp_profiles: tuple[McpServerProfile, ...] = tuple(profiles)
503
+ self.mcp_alias_index: dict[str, McpServerProfile] = {
504
+ alias: profile for profile in self.mcp_profiles for alias in profile.namespace_aliases
505
+ }
506
+
507
+ # Small lookup tables
508
+ self.npm_verify_scripts: frozenset[str] = frozenset(config.npm_verify_scripts)
509
+ self.interpreter_verify_modules: frozenset[str] = frozenset(
510
+ config.interpreter_verify_modules
511
+ )
512
+ self.git_subcmd_actions: dict[str, str] = dict(config.git_subcmd_actions)
513
+ self.verification_role_actions: dict[str, str] = dict(config.verification_role_actions)
514
+ self.transparent_wrappers: frozenset[str] = frozenset(config.transparent_wrappers)
515
+
516
+ # Activity→dimension defaults
517
+ ad = config.activity_defaults
518
+ self.activity_to_action: dict[str, str] = {
519
+ k: v.get("action", "") for k, v in ad.items() if "action" in v
520
+ }
521
+ self.activity_to_scope: dict[str, str] = {
522
+ k: v.get("scope", "") for k, v in ad.items() if "scope" in v
523
+ }
524
+ self.activity_to_phase: dict[str, str] = {
525
+ k: v.get("phase", "") for k, v in ad.items() if "phase" in v
526
+ }
527
+
528
+ # Risk scoring config (loaded separately as raw dict)
529
+ self.risk_config: dict[str, Any] | None = _load_risk_config()
530
+
531
+
532
+ # ── Default engine singleton ──
533
+
534
+ _default_engine: ClassificationEngine | None = None
535
+
536
+
537
+ def get_default_engine() -> ClassificationEngine:
538
+ """Get or lazily create the default ClassificationEngine."""
539
+ global _default_engine
540
+ if _default_engine is None:
541
+ _default_engine = ClassificationEngine(load_config())
542
+ return _default_engine
543
+
544
+
545
+ def set_default_engine(engine: ClassificationEngine | None) -> None:
546
+ """Replace the default engine (for testing or reconfiguration)."""
547
+ global _default_engine
548
+ _default_engine = engine
549
+
550
+
551
+ def reset_default_engine() -> None:
552
+ """Clear the cached default engine (forces reload on next access)."""
553
+ global _default_engine
554
+ _default_engine = None