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