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.
- traceforge/__init__.py +72 -0
- traceforge/__main__.py +5 -0
- traceforge/_generated.py +254 -0
- traceforge/adapters/__init__.py +12 -0
- traceforge/adapters/base.py +82 -0
- traceforge/adapters/genai_otel.py +164 -0
- traceforge/adapters/mapped_json.py +297 -0
- traceforge/adapters/otel.py +220 -0
- traceforge/boundary/__init__.py +46 -0
- traceforge/boundary/data/boundary-model.joblib +0 -0
- traceforge/boundary/decode.py +87 -0
- traceforge/boundary/features.py +137 -0
- traceforge/boundary/inference.py +267 -0
- traceforge/boundary/inferencer.py +146 -0
- traceforge/classify/__init__.py +95 -0
- traceforge/classify/cmd.py +109 -0
- traceforge/classify/coding.py +213 -0
- traceforge/classify/config.py +554 -0
- traceforge/classify/core.py +266 -0
- traceforge/classify/data/binary_info.yaml +358 -0
- traceforge/classify/data/canonical_tools.yaml +251 -0
- traceforge/classify/data/effect_overrides.yaml +480 -0
- traceforge/classify/data/mcp_profiles.yaml +711 -0
- traceforge/classify/data/recommendation_rules.yaml +111 -0
- traceforge/classify/data/risk.yaml +534 -0
- traceforge/classify/data/shell_defaults.yaml +95 -0
- traceforge/classify/data/shell_rules.yaml +1192 -0
- traceforge/classify/data/tool_classifications.yaml +215 -0
- traceforge/classify/data/verb_inference.yaml +218 -0
- traceforge/classify/mcp.py +181 -0
- traceforge/classify/phases.py +75 -0
- traceforge/classify/powershell.py +93 -0
- traceforge/classify/registry.py +138 -0
- traceforge/classify/risk.py +553 -0
- traceforge/classify/rules.py +215 -0
- traceforge/classify/schema.yaml +282 -0
- traceforge/classify/shell.py +503 -0
- traceforge/classify/tools.py +91 -0
- traceforge/classify/workflow.py +32 -0
- traceforge/cli/__init__.py +42 -0
- traceforge/cli/config_cmd.py +130 -0
- traceforge/cli/detect.py +46 -0
- traceforge/cli/download_cmd.py +74 -0
- traceforge/cli/factory.py +60 -0
- traceforge/cli/gate_cmd.py +30 -0
- traceforge/cli/init_cmd.py +86 -0
- traceforge/cli/replay.py +130 -0
- traceforge/cli/runner.py +181 -0
- traceforge/cli/score.py +217 -0
- traceforge/cli/status.py +65 -0
- traceforge/cli/watch.py +297 -0
- traceforge/config/__init__.py +80 -0
- traceforge/config/defaults.py +149 -0
- traceforge/config/loader.py +239 -0
- traceforge/config/mappings.py +104 -0
- traceforge/config/models.py +579 -0
- traceforge/enricher.py +576 -0
- traceforge/formatting/__init__.py +12 -0
- traceforge/formatting/budget.py +92 -0
- traceforge/formatting/density.py +154 -0
- traceforge/gate/__init__.py +17 -0
- traceforge/gate/client.py +145 -0
- traceforge/gate/external.py +305 -0
- traceforge/gate/registry.py +108 -0
- traceforge/gate/server.py +174 -0
- traceforge/gates/__init__.py +8 -0
- traceforge/gates/pii.py +352 -0
- traceforge/gates/pii_patterns.yaml +228 -0
- traceforge/governance/__init__.py +121 -0
- traceforge/governance/assessor.py +441 -0
- traceforge/governance/budget.py +59 -0
- traceforge/governance/canonical.py +56 -0
- traceforge/governance/codec.py +414 -0
- traceforge/governance/context.py +249 -0
- traceforge/governance/drift.py +196 -0
- traceforge/governance/emitter.py +234 -0
- traceforge/governance/envelope.py +138 -0
- traceforge/governance/ifc.py +364 -0
- traceforge/governance/integrity.py +162 -0
- traceforge/governance/labeler.py +250 -0
- traceforge/governance/mcp_drift.py +328 -0
- traceforge/governance/monitor.py +539 -0
- traceforge/governance/observer.py +276 -0
- traceforge/governance/persistence.py +401 -0
- traceforge/governance/phase1.py +77 -0
- traceforge/governance/pii.py +276 -0
- traceforge/governance/pipeline.py +840 -0
- traceforge/governance/registry.py +110 -0
- traceforge/governance/results.py +183 -0
- traceforge/governance/risk_wrapper.py +113 -0
- traceforge/governance/rules.py +368 -0
- traceforge/governance/scorer.py +262 -0
- traceforge/governance/shield.py +318 -0
- traceforge/governance/state.py +466 -0
- traceforge/governance/types.py +176 -0
- traceforge/mappings/__init__.py +1 -0
- traceforge/mappings/aider.yaml +216 -0
- traceforge/mappings/aider_markdown.yaml +113 -0
- traceforge/mappings/amazonq.yaml +56 -0
- traceforge/mappings/antigravity.yaml +88 -0
- traceforge/mappings/claude.yaml +93 -0
- traceforge/mappings/cline.yaml +286 -0
- traceforge/mappings/codex.yaml +158 -0
- traceforge/mappings/continue_dev.yaml +57 -0
- traceforge/mappings/copilot.yaml +266 -0
- traceforge/mappings/copilot_markdown.yaml +72 -0
- traceforge/mappings/copilot_vscode.yaml +181 -0
- traceforge/mappings/crewai.yaml +592 -0
- traceforge/mappings/goose.yaml +128 -0
- traceforge/mappings/langgraph.yaml +165 -0
- traceforge/mappings/maf.yaml +90 -0
- traceforge/mappings/maf_transcript.yaml +99 -0
- traceforge/mappings/openai_agents.yaml +144 -0
- traceforge/mappings/opencode.yaml +473 -0
- traceforge/mappings/openhands.yaml +320 -0
- traceforge/mappings/pydantic_ai.yaml +183 -0
- traceforge/mappings/smolagents.yaml +89 -0
- traceforge/mappings/sweagent.yaml +82 -0
- traceforge/migrations/__init__.py +1 -0
- traceforge/migrations/env.py +60 -0
- traceforge/migrations/models.py +145 -0
- traceforge/migrations/runner.py +44 -0
- traceforge/migrations/script.py.mako +25 -0
- traceforge/migrations/versions/0001_initial.py +176 -0
- traceforge/migrations/versions/__init__.py +4 -0
- traceforge/models.py +29 -0
- traceforge/parsers/__init__.py +21 -0
- traceforge/parsers/aider.py +416 -0
- traceforge/parsers/base.py +226 -0
- traceforge/parsers/copilot.py +314 -0
- traceforge/phase/__init__.py +50 -0
- traceforge/phase/data/phase-model.joblib +0 -0
- traceforge/phase/data/potion-base-8M/README.md +99 -0
- traceforge/phase/data/potion-base-8M/config.json +13 -0
- traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
- traceforge/phase/data/potion-base-8M/modules.json +14 -0
- traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
- traceforge/phase/event_rows.py +107 -0
- traceforge/phase/features.py +468 -0
- traceforge/phase/inference.py +279 -0
- traceforge/phase/inferencer.py +171 -0
- traceforge/phase/segmentation.py +258 -0
- traceforge/pipeline.py +891 -0
- traceforge/preprocessors/__init__.py +34 -0
- traceforge/preprocessors/amazonq.py +224 -0
- traceforge/preprocessors/antigravity.py +116 -0
- traceforge/preprocessors/claude.py +95 -0
- traceforge/preprocessors/cline.py +36 -0
- traceforge/preprocessors/codex.py +311 -0
- traceforge/preprocessors/continue_dev.py +119 -0
- traceforge/preprocessors/copilot_vscode.py +171 -0
- traceforge/preprocessors/goose.py +156 -0
- traceforge/preprocessors/maf_transcript.py +84 -0
- traceforge/preprocessors/openai_agents.py +86 -0
- traceforge/preprocessors/opencode.py +85 -0
- traceforge/preprocessors/openhands.py +36 -0
- traceforge/preprocessors/pydantic_ai.py +62 -0
- traceforge/preprocessors/registry.py +24 -0
- traceforge/preprocessors/smolagents.py +90 -0
- traceforge/py.typed +0 -0
- traceforge/sdk/__init__.py +59 -0
- traceforge/sdk/gate_policy.py +63 -0
- traceforge/sdk/gate_types.py +140 -0
- traceforge/sdk/pipeline.py +265 -0
- traceforge/sdk/verdict.py +81 -0
- traceforge/sinks/__init__.py +23 -0
- traceforge/sinks/base.py +95 -0
- traceforge/sinks/callback.py +132 -0
- traceforge/sinks/console.py +112 -0
- traceforge/sinks/factory.py +94 -0
- traceforge/sinks/jsonl.py +125 -0
- traceforge/sinks/otel_exporter.py +212 -0
- traceforge/sinks/parquet.py +260 -0
- traceforge/sinks/s3.py +206 -0
- traceforge/sinks/sqlite_output.py +234 -0
- traceforge/sinks/webhook.py +136 -0
- traceforge/sources/__init__.py +18 -0
- traceforge/sources/auto_detect.py +173 -0
- traceforge/sources/base.py +45 -0
- traceforge/sources/file_poll.py +136 -0
- traceforge/sources/file_watch.py +221 -0
- traceforge/sources/http_poll.py +141 -0
- traceforge/sources/replay.py +63 -0
- traceforge/sources/sqlite.py +187 -0
- traceforge/sources/sse.py +198 -0
- traceforge/telemetry/__init__.py +192 -0
- traceforge/title/__init__.py +23 -0
- traceforge/title/_resolve.py +79 -0
- traceforge/title/context.py +295 -0
- traceforge/title/data/boilerplate_files.json +14 -0
- traceforge/title/heuristics.py +429 -0
- traceforge/title/hygiene.py +90 -0
- traceforge/title/inference.py +314 -0
- traceforge/title/inferencer.py +477 -0
- traceforge/title/naming.py +398 -0
- traceforge/trace.py +291 -0
- traceforge/tracking/__init__.py +30 -0
- traceforge/tracking/models.py +115 -0
- traceforge/tracking/phase_tracker.py +288 -0
- traceforge/types.py +315 -0
- traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
- traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
- traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
- traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|