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,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
|
+
)
|