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,250 @@
|
|
|
1
|
+
"""GovernanceLabeler — Phase 2 of the governance pipeline.
|
|
2
|
+
|
|
3
|
+
Side-effect-free enrichment. ``capability`` and ``structure`` are unioned on
|
|
4
|
+
top of the existing Classification dimensions (labels are cumulative). By
|
|
5
|
+
contrast ``source_labels`` are *dynamic* IFC clearance labels — they are
|
|
6
|
+
computed FRESH for the current event and replace (never union with) whatever
|
|
7
|
+
the base classification carried, matching their exclusion from the canonical
|
|
8
|
+
action hash. Governance risk bonuses are accumulated through
|
|
9
|
+
``_RiskModifiersBuilder`` and bounded to their documented caps on ``freeze()``.
|
|
10
|
+
Returns GovernanceResult.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import dataclasses
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from traceforge.classify.core import Classification
|
|
21
|
+
from traceforge.governance.budget import BudgetThresholds
|
|
22
|
+
from traceforge.governance.drift import DriftAssessment, DriftDetector, DriftResult
|
|
23
|
+
from traceforge.governance.ifc import IFCChecker
|
|
24
|
+
from traceforge.governance.integrity import IntegrityVerifier
|
|
25
|
+
from traceforge.governance.mcp_drift import MCPIntegrityScanner
|
|
26
|
+
from traceforge.governance.pii import PIIScanner
|
|
27
|
+
from traceforge.governance.risk_wrapper import RiskModifiers
|
|
28
|
+
from traceforge.governance.types import EnrichmentContext
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class GovernanceResult:
|
|
33
|
+
"""Output of Phase 2 — enriched classification + risk modifiers."""
|
|
34
|
+
|
|
35
|
+
classification: "Classification"
|
|
36
|
+
risk_modifiers: "RiskModifiers"
|
|
37
|
+
drift_result: "DriftResult | None" = None
|
|
38
|
+
mcp_alerts: tuple = () # tuple[MCPIntegrityAlert, ...]
|
|
39
|
+
mcp_deferred_writes: tuple = () # tuple[MCPDeferredWrite, ...] — committed by pipeline after finalization
|
|
40
|
+
integrity_deferred_writes: tuple = () # tuple[IntegrityWrite, ...] — committed by pipeline after finalization
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _bounded(value: int, cap: int) -> int:
|
|
44
|
+
"""Clamp ``value`` to the inclusive range ``[0, cap]``."""
|
|
45
|
+
if value < 0:
|
|
46
|
+
return 0
|
|
47
|
+
return cap if value > cap else value
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _RiskModifiersBuilder:
|
|
51
|
+
"""Accumulator that bounds governance risk bonuses to their documented caps.
|
|
52
|
+
|
|
53
|
+
Each governance signal contributes additively to a modifier; ``freeze()``
|
|
54
|
+
bounds every accumulated value to the cap documented on the corresponding
|
|
55
|
+
:class:`~traceforge.governance.risk_wrapper.RiskModifiers` field, then
|
|
56
|
+
returns an immutable ``RiskModifiers``. Centralizing the caps here means no
|
|
57
|
+
individual accumulation site can emit a value that violates the
|
|
58
|
+
``RiskModifiers`` contract — e.g. the drift detector may return a phase
|
|
59
|
+
bonus up to 25, but ``phase_drift_bonus`` is documented "up to 20", so the
|
|
60
|
+
ceiling is enforced exactly once, on freeze.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
# Documented caps — mirror the RiskModifiers field docstrings.
|
|
64
|
+
PHASE_DRIFT_CAP = 20 # phase_drift_bonus: +10 per anomaly, up to 20
|
|
65
|
+
MCP_DRIFT_CAP = 40 # mcp_drift_bonus: severity-weighted, up to 40
|
|
66
|
+
IFC_VIOLATIONS_CAP = 3 # ifc_violations: +10 per violation up to 30 => 3 counts
|
|
67
|
+
INTEGRITY_CAP = 10 # integrity_bonus: +10 when integrity_unverified
|
|
68
|
+
BUDGET_CAP = 5 # budget_bonus: +5 under budget pressure
|
|
69
|
+
|
|
70
|
+
__slots__ = ("_phase_drift", "_mcp_drift", "_ifc_violations", "_integrity", "_budget")
|
|
71
|
+
|
|
72
|
+
def __init__(self) -> None:
|
|
73
|
+
self._phase_drift = 0
|
|
74
|
+
self._mcp_drift = 0
|
|
75
|
+
self._ifc_violations = 0
|
|
76
|
+
self._integrity = 0
|
|
77
|
+
self._budget = 0
|
|
78
|
+
|
|
79
|
+
def add_phase_drift(self, bonus: int) -> None:
|
|
80
|
+
"""Accumulate a phase-drift bonus (bounded to the cap on freeze)."""
|
|
81
|
+
self._phase_drift += bonus
|
|
82
|
+
|
|
83
|
+
def add_mcp_drift(self, bonus: int) -> None:
|
|
84
|
+
"""Accumulate an MCP fingerprint-drift bonus (bounded on freeze)."""
|
|
85
|
+
self._mcp_drift += bonus
|
|
86
|
+
|
|
87
|
+
def add_ifc_violation(self, count: int = 1) -> None:
|
|
88
|
+
"""Record IFC violation(s); the running count is bounded on freeze."""
|
|
89
|
+
self._ifc_violations += count
|
|
90
|
+
|
|
91
|
+
def set_integrity_unverified(self) -> None:
|
|
92
|
+
"""Flag content integrity as unverified (fixed integrity bonus)."""
|
|
93
|
+
self._integrity = self.INTEGRITY_CAP
|
|
94
|
+
|
|
95
|
+
def set_budget_pressure(self) -> None:
|
|
96
|
+
"""Flag budget pressure (fixed budget bonus)."""
|
|
97
|
+
self._budget = self.BUDGET_CAP
|
|
98
|
+
|
|
99
|
+
def freeze(self) -> "RiskModifiers":
|
|
100
|
+
"""Bound every accumulated modifier to its cap and return ``RiskModifiers``.
|
|
101
|
+
|
|
102
|
+
Pure: does not mutate the builder, so it may be called more than once.
|
|
103
|
+
"""
|
|
104
|
+
from traceforge.governance.risk_wrapper import RiskModifiers
|
|
105
|
+
|
|
106
|
+
return RiskModifiers(
|
|
107
|
+
phase_drift_bonus=_bounded(self._phase_drift, self.PHASE_DRIFT_CAP),
|
|
108
|
+
mcp_drift_bonus=_bounded(self._mcp_drift, self.MCP_DRIFT_CAP),
|
|
109
|
+
ifc_violations=_bounded(self._ifc_violations, self.IFC_VIOLATIONS_CAP),
|
|
110
|
+
integrity_bonus=_bounded(self._integrity, self.INTEGRITY_CAP),
|
|
111
|
+
budget_bonus=_bounded(self._budget, self.BUDGET_CAP),
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class GovernanceLabeler:
|
|
116
|
+
"""Phase 2 labeler. Stateless — all state comes via EnrichmentContext."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
pii_scanner: "PIIScanner | None" = None,
|
|
121
|
+
integrity_verifier: "IntegrityVerifier | None" = None,
|
|
122
|
+
mcp_scanner: "MCPIntegrityScanner | None" = None,
|
|
123
|
+
ifc_checker: "IFCChecker | None" = None,
|
|
124
|
+
drift_detector: "DriftDetector | None" = None,
|
|
125
|
+
budget_thresholds: "BudgetThresholds | None" = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
self._pii = pii_scanner
|
|
128
|
+
self._integrity = integrity_verifier
|
|
129
|
+
self._mcp = mcp_scanner
|
|
130
|
+
self._ifc = ifc_checker
|
|
131
|
+
self._drift = drift_detector
|
|
132
|
+
self._budget_thresholds = budget_thresholds
|
|
133
|
+
|
|
134
|
+
def label(self, ctx: "EnrichmentContext") -> GovernanceResult:
|
|
135
|
+
"""Enrich classification with governance labels. Side-effect-free."""
|
|
136
|
+
cap: set[str] = set()
|
|
137
|
+
struct: set[str] = set()
|
|
138
|
+
src_labels: set[str] = set()
|
|
139
|
+
modifiers = _RiskModifiersBuilder()
|
|
140
|
+
|
|
141
|
+
# PII scanning
|
|
142
|
+
if self._pii:
|
|
143
|
+
self._pii.scan(ctx, cap, struct)
|
|
144
|
+
|
|
145
|
+
# Content integrity — CHECK against the existing baseline (read-only), then
|
|
146
|
+
# collect deferred (re)baseline prescriptions. The check runs first so drift
|
|
147
|
+
# from a prior baseline is flagged before the monitor commits the new baseline.
|
|
148
|
+
integrity_deferred_writes: tuple = ()
|
|
149
|
+
if self._integrity:
|
|
150
|
+
self._integrity.check_event(ctx, cap)
|
|
151
|
+
integrity_deferred_writes = tuple(self._integrity.pending_writes(ctx))
|
|
152
|
+
|
|
153
|
+
# MCP fingerprint drift — scan returns typed MCPScanResult
|
|
154
|
+
mcp_alerts: tuple = ()
|
|
155
|
+
mcp_deferred_writes: tuple = ()
|
|
156
|
+
if self._mcp:
|
|
157
|
+
scan_result = self._mcp.scan(ctx, cap)
|
|
158
|
+
mcp_alerts = scan_result.alerts
|
|
159
|
+
mcp_deferred_writes = scan_result.deferred_writes
|
|
160
|
+
severity_map = {"critical": 20, "warning": 10, "info": 5}
|
|
161
|
+
modifiers.add_mcp_drift(sum(severity_map[a.severity] for a in mcp_alerts))
|
|
162
|
+
if mcp_alerts:
|
|
163
|
+
struct.add("semantic_drift")
|
|
164
|
+
|
|
165
|
+
# IFC source labels
|
|
166
|
+
if self._ifc and ctx.session_state:
|
|
167
|
+
self._ifc_label_only(ctx, src_labels)
|
|
168
|
+
all_caps = ctx.base_classification.capability | frozenset(cap)
|
|
169
|
+
# Filter out current event's taint to prevent self-violation
|
|
170
|
+
prior_taints = [
|
|
171
|
+
t
|
|
172
|
+
for t in ctx.session_state.taint_ledger
|
|
173
|
+
if t.source_event_key != ctx.event.source_event_key
|
|
174
|
+
]
|
|
175
|
+
if prior_taints and ctx.base_classification.effect in ("mutating", "destructive"):
|
|
176
|
+
struct.add("ifc_violation")
|
|
177
|
+
modifiers.add_ifc_violation()
|
|
178
|
+
elif any("ifc:" in l for l in src_labels) and "network_outbound" in all_caps:
|
|
179
|
+
struct.add("ifc_violation")
|
|
180
|
+
modifiers.add_ifc_violation()
|
|
181
|
+
|
|
182
|
+
# Phase drift — returns typed DriftAssessment with risk_bonus field
|
|
183
|
+
drift_result: "DriftAssessment | None" = None
|
|
184
|
+
if self._drift and ctx.session_state:
|
|
185
|
+
drift_result = self._drift.detect(ctx, ctx.session_state, cap)
|
|
186
|
+
if drift_result:
|
|
187
|
+
modifiers.add_phase_drift(drift_result.risk_bonus)
|
|
188
|
+
if drift_result.anomaly:
|
|
189
|
+
struct.add("phase_anomaly")
|
|
190
|
+
|
|
191
|
+
# Budget pressure check (read-only from snapshot — no thresholds dependency)
|
|
192
|
+
if ctx.session_state and ctx.session_state.budget.pressure:
|
|
193
|
+
cap.add("budget_pressure")
|
|
194
|
+
modifiers.set_budget_pressure()
|
|
195
|
+
|
|
196
|
+
# Integrity bonus — surfaced by the integrity check above via the cap label.
|
|
197
|
+
if "integrity_unverified" in cap:
|
|
198
|
+
modifiers.set_integrity_unverified()
|
|
199
|
+
|
|
200
|
+
# Merge labels into classification. capability/structure are cumulative
|
|
201
|
+
# (unioned with the base); source_labels are dynamic IFC clearance labels
|
|
202
|
+
# set FRESH for this event (never carried forward from the base).
|
|
203
|
+
enriched = dataclasses.replace(
|
|
204
|
+
ctx.base_classification,
|
|
205
|
+
capability=ctx.base_classification.capability | frozenset(cap),
|
|
206
|
+
structure=ctx.base_classification.structure | frozenset(struct),
|
|
207
|
+
source_labels=frozenset(src_labels),
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
return GovernanceResult(
|
|
211
|
+
classification=enriched,
|
|
212
|
+
risk_modifiers=modifiers.freeze(),
|
|
213
|
+
drift_result=drift_result,
|
|
214
|
+
mcp_alerts=mcp_alerts,
|
|
215
|
+
mcp_deferred_writes=mcp_deferred_writes,
|
|
216
|
+
integrity_deferred_writes=integrity_deferred_writes,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
def _ifc_label_only(self, ctx: "EnrichmentContext", src_labels: set[str]) -> None:
|
|
220
|
+
"""Read-only IFC label assignment from snapshot state."""
|
|
221
|
+
if not ctx.session_state:
|
|
222
|
+
return
|
|
223
|
+
# If there are existing taints and current event writes, propagate label
|
|
224
|
+
if ctx.session_state.taint_ledger:
|
|
225
|
+
max_clearance = max(
|
|
226
|
+
(t.clearance for t in ctx.session_state.taint_ledger),
|
|
227
|
+
key=lambda c: {"public": 0, "internal": 1, "confidential": 2, "secret": 3}.get(
|
|
228
|
+
c, 0
|
|
229
|
+
),
|
|
230
|
+
default=None,
|
|
231
|
+
)
|
|
232
|
+
if max_clearance:
|
|
233
|
+
src_labels.add(f"ifc:{max_clearance}")
|
|
234
|
+
|
|
235
|
+
# ── Public facade methods for pipeline (eliminate private member access) ──
|
|
236
|
+
|
|
237
|
+
@property
|
|
238
|
+
def has_ifc(self) -> bool:
|
|
239
|
+
"""Whether IFC checker is configured."""
|
|
240
|
+
return self._ifc is not None
|
|
241
|
+
|
|
242
|
+
def check_ifc(self, ctx: "EnrichmentContext", src_labels: set[str], state) -> None:
|
|
243
|
+
"""Run IFC check (Phase 1 taint propagation). Delegates to IFCChecker."""
|
|
244
|
+
if self._ifc:
|
|
245
|
+
self._ifc.check(ctx, src_labels, state)
|
|
246
|
+
|
|
247
|
+
@property
|
|
248
|
+
def has_mcp_scanner(self) -> bool:
|
|
249
|
+
"""Whether MCP integrity scanner is configured."""
|
|
250
|
+
return self._mcp is not None
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"""MCP tool fingerprint drift detection with per-dimension semantic analysis."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from typing import TYPE_CHECKING, Literal
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from traceforge.classify.core import Classification
|
|
14
|
+
from traceforge.governance.persistence import SystemStore
|
|
15
|
+
from traceforge.governance.types import EnrichmentContext
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class MCPToolProfile:
|
|
20
|
+
"""Frozen fingerprint of an MCP tool at first-seen time."""
|
|
21
|
+
|
|
22
|
+
tool_name: str
|
|
23
|
+
server_namespace: str
|
|
24
|
+
description_hash: str
|
|
25
|
+
schema_hash: str
|
|
26
|
+
registered_effect: str | None
|
|
27
|
+
registered_role: frozenset[str]
|
|
28
|
+
registered_capabilities: frozenset[str]
|
|
29
|
+
registered_scope: frozenset[str]
|
|
30
|
+
clearance: str | None
|
|
31
|
+
first_seen: datetime
|
|
32
|
+
last_seen: datetime
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class MCPIntegrityAlert:
|
|
37
|
+
"""Individual drift alert with severity."""
|
|
38
|
+
|
|
39
|
+
tool_name: str
|
|
40
|
+
server: str
|
|
41
|
+
alert_type: Literal[
|
|
42
|
+
"effect_escalation",
|
|
43
|
+
"capability_gain",
|
|
44
|
+
"scope_expansion",
|
|
45
|
+
"description_change",
|
|
46
|
+
"schema_change",
|
|
47
|
+
"adversarial_pattern",
|
|
48
|
+
]
|
|
49
|
+
previous: str
|
|
50
|
+
current: str
|
|
51
|
+
severity: Literal["info", "warning", "critical"]
|
|
52
|
+
timestamp: datetime
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# Effect precedence for escalation detection
|
|
56
|
+
_EFFECT_ORDER = {"read_only": 0, "informational": 1, "mutating": 2, "destructive": 3}
|
|
57
|
+
# Dangerous capabilities that trigger alerts when gained
|
|
58
|
+
_DANGEROUS_CAPS = frozenset(
|
|
59
|
+
{"network_outbound", "elevated_privilege", "arbitrary_execution", "credential_exposure"}
|
|
60
|
+
)
|
|
61
|
+
# Scope escalation (higher = broader)
|
|
62
|
+
_SCOPE_ORDER = {"file": 0, "project": 1, "repository": 2, "host": 3, "network": 4}
|
|
63
|
+
|
|
64
|
+
# Adversarial patterns in tool descriptions
|
|
65
|
+
_ADVERSARIAL_PATTERNS = [
|
|
66
|
+
re.compile(r"[\u200b-\u200f\u2028-\u202f\u2060-\u206f\ufeff]"), # Invisible unicode
|
|
67
|
+
re.compile(
|
|
68
|
+
r"(?:ignore|disregard|forget)\s+(?:previous|prior|above)\s+(?:instructions?|rules?|constraints?)",
|
|
69
|
+
re.IGNORECASE,
|
|
70
|
+
), # Prompt injection
|
|
71
|
+
re.compile(r"(?:you\s+are|act\s+as|pretend|role[-\s]?play)", re.IGNORECASE), # Role override
|
|
72
|
+
re.compile(
|
|
73
|
+
r"(?<![A-Fa-f0-9])[A-Za-z0-9+/]{40,}(?:={1,2})(?![A-Za-z0-9+/=])"
|
|
74
|
+
), # Base64 payload (requires padding, excludes hex-only)
|
|
75
|
+
re.compile(r"<!--.*?-->", re.DOTALL), # Hidden HTML comments
|
|
76
|
+
re.compile(r"\{%.*?%\}", re.DOTALL), # Template injection
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True)
|
|
81
|
+
class MCPDeferredWrite:
|
|
82
|
+
"""Immutable deferred DB write — committed only after pipeline finalization."""
|
|
83
|
+
|
|
84
|
+
kind: Literal["upsert", "last_seen"]
|
|
85
|
+
server: str
|
|
86
|
+
tool_name: str
|
|
87
|
+
payload: str # JSON for upsert, ISO timestamp for last_seen
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(frozen=True)
|
|
91
|
+
class MCPScanResult:
|
|
92
|
+
"""Complete scan output — alerts, novelty flag, and deferred writes."""
|
|
93
|
+
|
|
94
|
+
alerts: tuple[MCPIntegrityAlert, ...]
|
|
95
|
+
is_new: bool
|
|
96
|
+
deferred_writes: tuple[MCPDeferredWrite, ...]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class MCPIntegrityScanner:
|
|
100
|
+
"""Full MCP integrity scanning: fingerprint drift + semantic analysis + adversarial detection."""
|
|
101
|
+
|
|
102
|
+
def __init__(self, store: "SystemStore") -> None:
|
|
103
|
+
self._store = store
|
|
104
|
+
|
|
105
|
+
def scan(self, ctx: "EnrichmentContext", cap: set[str]) -> MCPScanResult:
|
|
106
|
+
"""Full scan: fingerprint comparison + semantic drift + adversarial patterns.
|
|
107
|
+
|
|
108
|
+
Returns MCPScanResult with alerts, is_new flag, and deferred writes.
|
|
109
|
+
Deferred writes MUST only be committed after pipeline finalization.
|
|
110
|
+
"""
|
|
111
|
+
from traceforge.governance.types import ToolCallEvent
|
|
112
|
+
|
|
113
|
+
if not isinstance(ctx.event, ToolCallEvent):
|
|
114
|
+
return MCPScanResult(alerts=(), is_new=False, deferred_writes=())
|
|
115
|
+
|
|
116
|
+
server = ctx.event.mcp_server_name or ""
|
|
117
|
+
tool_name = ctx.event.tool_name or ""
|
|
118
|
+
if not server or not tool_name:
|
|
119
|
+
return MCPScanResult(alerts=(), is_new=False, deferred_writes=())
|
|
120
|
+
|
|
121
|
+
alerts: list[MCPIntegrityAlert] = []
|
|
122
|
+
deferred: list[MCPDeferredWrite] = []
|
|
123
|
+
now = datetime.now(timezone.utc)
|
|
124
|
+
|
|
125
|
+
# Compute current fingerprints
|
|
126
|
+
desc_hash = self._hash(ctx.event.tool_description or "")
|
|
127
|
+
schema_hash = self._hash(ctx.event.tool_schema_json or "")
|
|
128
|
+
|
|
129
|
+
# Check stored profile
|
|
130
|
+
stored = self._store.get_mcp_profile(server, tool_name)
|
|
131
|
+
|
|
132
|
+
if stored is None:
|
|
133
|
+
# First time — defer registration until pipeline finalization
|
|
134
|
+
cls = ctx.base_classification
|
|
135
|
+
deferred.append(
|
|
136
|
+
MCPDeferredWrite(
|
|
137
|
+
kind="upsert",
|
|
138
|
+
server=server,
|
|
139
|
+
tool_name=tool_name,
|
|
140
|
+
payload=json.dumps(
|
|
141
|
+
{
|
|
142
|
+
"description_hash": desc_hash,
|
|
143
|
+
"schema_hash": schema_hash,
|
|
144
|
+
"registered_effect": cls.effect,
|
|
145
|
+
"role": sorted(cls.role),
|
|
146
|
+
"capability": sorted(cls.capability),
|
|
147
|
+
"scope": sorted(cls.scope),
|
|
148
|
+
"clearance": None,
|
|
149
|
+
"first_seen": now.isoformat(),
|
|
150
|
+
"last_seen": now.isoformat(),
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
# Still scan description for adversarial content on first-seen
|
|
156
|
+
desc_alerts = self.scan_description(
|
|
157
|
+
ctx.event.tool_description or "", tool_name, server, now
|
|
158
|
+
)
|
|
159
|
+
if desc_alerts:
|
|
160
|
+
alerts.extend(desc_alerts)
|
|
161
|
+
cap.add("mcp_drift")
|
|
162
|
+
return MCPScanResult(
|
|
163
|
+
alerts=tuple(alerts),
|
|
164
|
+
is_new=True,
|
|
165
|
+
deferred_writes=tuple(deferred),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# ── Fingerprint comparison ──
|
|
169
|
+
if stored["description_hash"] != desc_hash:
|
|
170
|
+
alerts.append(
|
|
171
|
+
MCPIntegrityAlert(
|
|
172
|
+
tool_name=tool_name,
|
|
173
|
+
server=server,
|
|
174
|
+
alert_type="description_change",
|
|
175
|
+
previous=stored["description_hash"][:16],
|
|
176
|
+
current=desc_hash[:16],
|
|
177
|
+
severity="warning",
|
|
178
|
+
timestamp=now,
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if stored["schema_hash"] != schema_hash:
|
|
183
|
+
alerts.append(
|
|
184
|
+
MCPIntegrityAlert(
|
|
185
|
+
tool_name=tool_name,
|
|
186
|
+
server=server,
|
|
187
|
+
alert_type="schema_change",
|
|
188
|
+
previous=stored["schema_hash"][:16],
|
|
189
|
+
current=schema_hash[:16],
|
|
190
|
+
severity="critical",
|
|
191
|
+
timestamp=now,
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
# ── Semantic drift: per-dimension comparison ──
|
|
196
|
+
semantic_alerts = self.check_semantic_drift(
|
|
197
|
+
tool_name, server, ctx.base_classification, stored, now
|
|
198
|
+
)
|
|
199
|
+
alerts.extend(semantic_alerts)
|
|
200
|
+
|
|
201
|
+
# ── Adversarial pattern scanning ──
|
|
202
|
+
desc_alerts = self.scan_description(
|
|
203
|
+
ctx.event.tool_description or "", tool_name, server, now
|
|
204
|
+
)
|
|
205
|
+
alerts.extend(desc_alerts)
|
|
206
|
+
|
|
207
|
+
# Apply labels based on alert severity
|
|
208
|
+
if alerts:
|
|
209
|
+
max_severity = max(a.severity for a in alerts)
|
|
210
|
+
if max_severity in ("warning", "critical"):
|
|
211
|
+
cap.add("mcp_drift")
|
|
212
|
+
|
|
213
|
+
# Defer last_seen update until pipeline finalization
|
|
214
|
+
deferred.append(
|
|
215
|
+
MCPDeferredWrite(
|
|
216
|
+
kind="last_seen",
|
|
217
|
+
server=server,
|
|
218
|
+
tool_name=tool_name,
|
|
219
|
+
payload=now.isoformat(),
|
|
220
|
+
)
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return MCPScanResult(
|
|
224
|
+
alerts=tuple(alerts),
|
|
225
|
+
is_new=False,
|
|
226
|
+
deferred_writes=tuple(deferred),
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def check_semantic_drift(
|
|
230
|
+
self,
|
|
231
|
+
tool: str,
|
|
232
|
+
server: str,
|
|
233
|
+
current: "Classification",
|
|
234
|
+
stored: dict,
|
|
235
|
+
now: datetime,
|
|
236
|
+
) -> list[MCPIntegrityAlert]:
|
|
237
|
+
"""Per-dimension comparison against registered profile."""
|
|
238
|
+
alerts: list[MCPIntegrityAlert] = []
|
|
239
|
+
|
|
240
|
+
# Effect escalation
|
|
241
|
+
reg_effect = stored.get("registered_effect")
|
|
242
|
+
if reg_effect and current.effect:
|
|
243
|
+
prev_order = _EFFECT_ORDER.get(reg_effect, 0)
|
|
244
|
+
curr_order = _EFFECT_ORDER.get(current.effect, 0)
|
|
245
|
+
if curr_order > prev_order:
|
|
246
|
+
severity = "critical" if curr_order >= 3 else "warning"
|
|
247
|
+
alerts.append(
|
|
248
|
+
MCPIntegrityAlert(
|
|
249
|
+
tool_name=tool,
|
|
250
|
+
server=server,
|
|
251
|
+
alert_type="effect_escalation",
|
|
252
|
+
previous=reg_effect,
|
|
253
|
+
current=current.effect,
|
|
254
|
+
severity=severity,
|
|
255
|
+
timestamp=now,
|
|
256
|
+
)
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# Capability gain
|
|
260
|
+
reg_caps = frozenset(stored.get("capability") or ())
|
|
261
|
+
new_caps = current.capability - reg_caps
|
|
262
|
+
dangerous_new = new_caps & _DANGEROUS_CAPS
|
|
263
|
+
if dangerous_new:
|
|
264
|
+
alerts.append(
|
|
265
|
+
MCPIntegrityAlert(
|
|
266
|
+
tool_name=tool,
|
|
267
|
+
server=server,
|
|
268
|
+
alert_type="capability_gain",
|
|
269
|
+
previous=",".join(sorted(reg_caps)),
|
|
270
|
+
current=",".join(sorted(dangerous_new)),
|
|
271
|
+
severity="critical",
|
|
272
|
+
timestamp=now,
|
|
273
|
+
)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# Scope expansion
|
|
277
|
+
reg_scope = frozenset(stored.get("scope") or ())
|
|
278
|
+
new_scope = current.scope - reg_scope
|
|
279
|
+
if new_scope:
|
|
280
|
+
max_prev = max((_SCOPE_ORDER.get(s, 0) for s in reg_scope), default=0)
|
|
281
|
+
max_curr = max((_SCOPE_ORDER.get(s, 0) for s in new_scope), default=0)
|
|
282
|
+
if max_curr > max_prev:
|
|
283
|
+
alerts.append(
|
|
284
|
+
MCPIntegrityAlert(
|
|
285
|
+
tool_name=tool,
|
|
286
|
+
server=server,
|
|
287
|
+
alert_type="scope_expansion",
|
|
288
|
+
previous=",".join(sorted(reg_scope)),
|
|
289
|
+
current=",".join(sorted(new_scope)),
|
|
290
|
+
severity="warning" if max_curr < 3 else "critical",
|
|
291
|
+
timestamp=now,
|
|
292
|
+
)
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
return alerts
|
|
296
|
+
|
|
297
|
+
def scan_description(
|
|
298
|
+
self,
|
|
299
|
+
description: str,
|
|
300
|
+
tool_name: str,
|
|
301
|
+
server: str,
|
|
302
|
+
now: datetime,
|
|
303
|
+
) -> list[MCPIntegrityAlert]:
|
|
304
|
+
"""Detect adversarial patterns: invisible unicode, prompt injection, base64, hidden markup."""
|
|
305
|
+
if not description:
|
|
306
|
+
return []
|
|
307
|
+
|
|
308
|
+
alerts: list[MCPIntegrityAlert] = []
|
|
309
|
+
for pattern in _ADVERSARIAL_PATTERNS:
|
|
310
|
+
match = pattern.search(description)
|
|
311
|
+
if match:
|
|
312
|
+
alerts.append(
|
|
313
|
+
MCPIntegrityAlert(
|
|
314
|
+
tool_name=tool_name,
|
|
315
|
+
server=server,
|
|
316
|
+
alert_type="adversarial_pattern",
|
|
317
|
+
previous="clean",
|
|
318
|
+
current=f"pattern:{pattern.pattern[:40]}",
|
|
319
|
+
severity="critical",
|
|
320
|
+
timestamp=now,
|
|
321
|
+
)
|
|
322
|
+
)
|
|
323
|
+
break # One alert is enough
|
|
324
|
+
|
|
325
|
+
return alerts
|
|
326
|
+
|
|
327
|
+
def _hash(self, content: str) -> str:
|
|
328
|
+
return hashlib.sha256(content.encode()).hexdigest()
|