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,466 @@
|
|
|
1
|
+
"""Session state management with SQLite write-through persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from collections import Counter, deque
|
|
9
|
+
from contextlib import nullcontext
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime, timezone
|
|
12
|
+
|
|
13
|
+
_BUDGET_DIMENSIONS = (
|
|
14
|
+
"effect",
|
|
15
|
+
"mechanism",
|
|
16
|
+
"scope",
|
|
17
|
+
"role",
|
|
18
|
+
"phase",
|
|
19
|
+
"capability",
|
|
20
|
+
"action",
|
|
21
|
+
"structure",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class TaintEntry:
|
|
27
|
+
"""Immutable taint record for IFC lineage tracking."""
|
|
28
|
+
|
|
29
|
+
event_id: str
|
|
30
|
+
source_event_key: str # Unique per event — used for self-taint filtering
|
|
31
|
+
clearance: str # Clearance enum value
|
|
32
|
+
source: str # "file_read", "tool_output", "user_input"
|
|
33
|
+
payload_pointer: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class BudgetSnapshot:
|
|
38
|
+
"""Immutable point-in-time view of budget counters."""
|
|
39
|
+
|
|
40
|
+
total_tool_calls: int = 0
|
|
41
|
+
total_tokens: int = 0
|
|
42
|
+
elapsed_seconds: float = 0.0
|
|
43
|
+
by_effect: tuple[tuple[str, int], ...] = ()
|
|
44
|
+
by_capability: tuple[tuple[str, int], ...] = ()
|
|
45
|
+
by_scope: tuple[tuple[str, int], ...] = ()
|
|
46
|
+
by_role: tuple[tuple[str, int], ...] = ()
|
|
47
|
+
by_phase: tuple[tuple[str, int], ...] = ()
|
|
48
|
+
by_mechanism: tuple[tuple[str, int], ...] = ()
|
|
49
|
+
by_action: tuple[tuple[str, int], ...] = ()
|
|
50
|
+
by_structure: tuple[tuple[str, int], ...] = ()
|
|
51
|
+
pressure: bool = False
|
|
52
|
+
|
|
53
|
+
def count(self, dimension: str, key: str) -> int:
|
|
54
|
+
"""Lookup a count by dimension name and key."""
|
|
55
|
+
dimension_map = {
|
|
56
|
+
"effect": self.by_effect,
|
|
57
|
+
"capability": self.by_capability,
|
|
58
|
+
"scope": self.by_scope,
|
|
59
|
+
"role": self.by_role,
|
|
60
|
+
"phase": self.by_phase,
|
|
61
|
+
"mechanism": self.by_mechanism,
|
|
62
|
+
"action": self.by_action,
|
|
63
|
+
"structure": self.by_structure,
|
|
64
|
+
}
|
|
65
|
+
entries = dimension_map.get(dimension, ())
|
|
66
|
+
for k, v in entries:
|
|
67
|
+
if k == key:
|
|
68
|
+
return v
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class SessionStateSnapshot:
|
|
74
|
+
"""Immutable deep-copy of session state, taken after Phase 1 completes."""
|
|
75
|
+
|
|
76
|
+
budget: BudgetSnapshot
|
|
77
|
+
phase_window: tuple[str, ...] = ()
|
|
78
|
+
taint_ledger: tuple[TaintEntry, ...] = ()
|
|
79
|
+
last_assistant_event_id: str | None = None
|
|
80
|
+
last_user_event_id: str | None = None
|
|
81
|
+
event_count: int = 0
|
|
82
|
+
dropped_events: int = 0
|
|
83
|
+
last_sequence: int | None = None
|
|
84
|
+
gap_ordinal: int = 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class SessionState:
|
|
89
|
+
"""Mutable in-memory session state with SQLite write-through."""
|
|
90
|
+
|
|
91
|
+
session_id: str
|
|
92
|
+
_budget_counters: dict[str, Counter] = field(default_factory=dict)
|
|
93
|
+
_total_tool_calls: int = 0
|
|
94
|
+
_total_tokens: int = 0
|
|
95
|
+
_elapsed_seconds: float = 0.0
|
|
96
|
+
_pressure: bool = False
|
|
97
|
+
_phase_window: list[str] = field(default_factory=list)
|
|
98
|
+
_taint_ledger: list[TaintEntry] = field(default_factory=list)
|
|
99
|
+
_last_assistant_event_id: str | None = None
|
|
100
|
+
_last_user_event_id: str | None = None
|
|
101
|
+
_event_count: int = 0
|
|
102
|
+
_dropped_events: int = 0
|
|
103
|
+
_last_sequence: int | None = None
|
|
104
|
+
_gap_ordinal: int = 0
|
|
105
|
+
_db: sqlite3.Connection | None = None
|
|
106
|
+
# Writer lock shared with the owning SystemStore. When present, a standalone
|
|
107
|
+
# ``persist`` acquires it for the whole transaction so it is serialized against
|
|
108
|
+
# the monitor's ``write_transaction`` on the shared connection (single writer).
|
|
109
|
+
_lock: "threading.Lock | None" = None
|
|
110
|
+
# Shield (enforcement) decision log. The tool-call counter itself is NOT
|
|
111
|
+
# here — it is _total_tool_calls above, advanced only by increment_budget.
|
|
112
|
+
_denied_count: int = 0
|
|
113
|
+
_prior_verdicts: deque = field(default_factory=lambda: deque(maxlen=100))
|
|
114
|
+
_prior_tool_call_ids: deque = field(default_factory=lambda: deque(maxlen=100))
|
|
115
|
+
|
|
116
|
+
PHASE_WINDOW_SIZE: int = 20
|
|
117
|
+
TAINT_LEDGER_MAX: int = 200
|
|
118
|
+
|
|
119
|
+
def __post_init__(self) -> None:
|
|
120
|
+
if not self._budget_counters:
|
|
121
|
+
self._budget_counters = {
|
|
122
|
+
"effect": Counter(),
|
|
123
|
+
"capability": Counter(),
|
|
124
|
+
"scope": Counter(),
|
|
125
|
+
"role": Counter(),
|
|
126
|
+
"phase": Counter(),
|
|
127
|
+
"mechanism": Counter(),
|
|
128
|
+
"action": Counter(),
|
|
129
|
+
"structure": Counter(),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
def attach_db(
|
|
133
|
+
self, db: sqlite3.Connection | None, lock: "threading.Lock | None" = None
|
|
134
|
+
) -> None:
|
|
135
|
+
self._db = db
|
|
136
|
+
self._lock = lock
|
|
137
|
+
|
|
138
|
+
def increment_budget(
|
|
139
|
+
self,
|
|
140
|
+
*,
|
|
141
|
+
mechanism: str | None = None,
|
|
142
|
+
effect: str | None = None,
|
|
143
|
+
scope: frozenset[str] = frozenset(),
|
|
144
|
+
role: frozenset[str] = frozenset(),
|
|
145
|
+
action: frozenset[str] = frozenset(),
|
|
146
|
+
capability: frozenset[str] = frozenset(),
|
|
147
|
+
structure: frozenset[str] = frozenset(),
|
|
148
|
+
phase: str | None = None,
|
|
149
|
+
) -> None:
|
|
150
|
+
"""Increment budget counters for a classified event."""
|
|
151
|
+
self._total_tool_calls += 1
|
|
152
|
+
if mechanism:
|
|
153
|
+
self._budget_counters["mechanism"][mechanism] += 1
|
|
154
|
+
if effect:
|
|
155
|
+
self._budget_counters["effect"][effect] += 1
|
|
156
|
+
for v in scope:
|
|
157
|
+
self._budget_counters["scope"][v] += 1
|
|
158
|
+
for v in role:
|
|
159
|
+
self._budget_counters["role"][v] += 1
|
|
160
|
+
for v in action:
|
|
161
|
+
self._budget_counters["action"][v] += 1
|
|
162
|
+
for v in capability:
|
|
163
|
+
self._budget_counters["capability"][v] += 1
|
|
164
|
+
for v in structure:
|
|
165
|
+
self._budget_counters["structure"][v] += 1
|
|
166
|
+
if phase:
|
|
167
|
+
self._budget_counters["phase"][phase] += 1
|
|
168
|
+
|
|
169
|
+
def update_phase_window(self, phase: str) -> None:
|
|
170
|
+
self._phase_window.append(phase)
|
|
171
|
+
if len(self._phase_window) > self.PHASE_WINDOW_SIZE:
|
|
172
|
+
self._phase_window = self._phase_window[-self.PHASE_WINDOW_SIZE :]
|
|
173
|
+
|
|
174
|
+
def add_taint(self, entry: TaintEntry) -> None:
|
|
175
|
+
self._taint_ledger.append(entry)
|
|
176
|
+
if len(self._taint_ledger) > self.TAINT_LEDGER_MAX:
|
|
177
|
+
self._taint_ledger = self._taint_ledger[-self.TAINT_LEDGER_MAX :]
|
|
178
|
+
|
|
179
|
+
def record_event(self, sequence: int | None = None) -> None:
|
|
180
|
+
self._event_count += 1
|
|
181
|
+
if sequence is not None:
|
|
182
|
+
self._last_sequence = sequence
|
|
183
|
+
|
|
184
|
+
def record_drop(self, count: int) -> None:
|
|
185
|
+
self._dropped_events += count
|
|
186
|
+
self._gap_ordinal += 1
|
|
187
|
+
|
|
188
|
+
@property
|
|
189
|
+
def taint_ledger(self) -> list[TaintEntry]:
|
|
190
|
+
"""Public read access to taint ledger for IFC checks."""
|
|
191
|
+
return self._taint_ledger
|
|
192
|
+
|
|
193
|
+
def set_last_assistant(self, event_id: str) -> None:
|
|
194
|
+
self._last_assistant_event_id = event_id
|
|
195
|
+
|
|
196
|
+
def set_last_user(self, event_id: str) -> None:
|
|
197
|
+
self._last_user_event_id = event_id
|
|
198
|
+
|
|
199
|
+
# ─── Enforcement (shield) decision tracking ──────────────────────────────
|
|
200
|
+
# The tool-call counter is single-writer: it advances ONLY through
|
|
201
|
+
# increment_budget() when a call is observed. The shield READS
|
|
202
|
+
# tool_call_count and owns only its own denial/allow decision log below.
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def tool_call_count(self) -> int:
|
|
206
|
+
"""Tool calls observed this session — the single source of truth."""
|
|
207
|
+
return self._total_tool_calls
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def denied_count(self) -> int:
|
|
211
|
+
"""Tool calls the shield has denied this session."""
|
|
212
|
+
return self._denied_count
|
|
213
|
+
|
|
214
|
+
def prior_verdicts(self) -> tuple:
|
|
215
|
+
"""Recent enforcement verdicts (most-recent-last, bounded)."""
|
|
216
|
+
return tuple(self._prior_verdicts)
|
|
217
|
+
|
|
218
|
+
def prior_tool_call_ids(self) -> tuple[str, ...]:
|
|
219
|
+
"""Recent allowed tool-call ids (most-recent-last, bounded)."""
|
|
220
|
+
return tuple(self._prior_tool_call_ids)
|
|
221
|
+
|
|
222
|
+
def record_denial(self, verdict) -> None:
|
|
223
|
+
"""Record a shield denial. Does NOT touch the tool-call counter."""
|
|
224
|
+
self._denied_count += 1
|
|
225
|
+
self._prior_verdicts.append(verdict) # deque(maxlen=100) auto-evicts
|
|
226
|
+
|
|
227
|
+
def record_allow(self, tool_call_id: str | None = None) -> None:
|
|
228
|
+
"""Record a shield allow. Logs the id only — the counter advances via
|
|
229
|
+
observation (increment_budget), never here."""
|
|
230
|
+
if tool_call_id:
|
|
231
|
+
self._prior_tool_call_ids.append(tool_call_id) # deque(maxlen=100)
|
|
232
|
+
|
|
233
|
+
def check_pressure(self, thresholds: dict | None) -> bool:
|
|
234
|
+
"""Check if any threshold is exceeded. Updates pressure flag."""
|
|
235
|
+
if not thresholds:
|
|
236
|
+
self._pressure = False
|
|
237
|
+
return False
|
|
238
|
+
max_calls = thresholds.get("max_tool_calls")
|
|
239
|
+
if max_calls and self._total_tool_calls >= max_calls:
|
|
240
|
+
self._pressure = True
|
|
241
|
+
return True
|
|
242
|
+
for dim_key, limits in thresholds.items():
|
|
243
|
+
if dim_key.startswith("max_by_") and isinstance(limits, dict):
|
|
244
|
+
dim = dim_key[len("max_by_") :]
|
|
245
|
+
counter = self._budget_counters.get(dim, Counter())
|
|
246
|
+
for key, limit in limits.items():
|
|
247
|
+
if counter[key] >= limit:
|
|
248
|
+
self._pressure = True
|
|
249
|
+
return True
|
|
250
|
+
self._pressure = False
|
|
251
|
+
return False
|
|
252
|
+
|
|
253
|
+
def clone_detached(self) -> "SessionState":
|
|
254
|
+
"""Create a mutable deep copy with no DB connection (for preflight simulation).
|
|
255
|
+
|
|
256
|
+
This is safe to call concurrently — it never touches self._db.
|
|
257
|
+
The returned copy has _db=None so persist calls are no-ops.
|
|
258
|
+
"""
|
|
259
|
+
from collections import Counter as _Counter
|
|
260
|
+
from copy import deepcopy
|
|
261
|
+
|
|
262
|
+
return SessionState(
|
|
263
|
+
session_id=self.session_id,
|
|
264
|
+
_budget_counters={k: _Counter(v) for k, v in self._budget_counters.items()},
|
|
265
|
+
_total_tool_calls=self._total_tool_calls,
|
|
266
|
+
_total_tokens=self._total_tokens,
|
|
267
|
+
_elapsed_seconds=self._elapsed_seconds,
|
|
268
|
+
_pressure=self._pressure,
|
|
269
|
+
_phase_window=list(self._phase_window),
|
|
270
|
+
_taint_ledger=deepcopy(self._taint_ledger),
|
|
271
|
+
_last_assistant_event_id=self._last_assistant_event_id,
|
|
272
|
+
_last_user_event_id=self._last_user_event_id,
|
|
273
|
+
_event_count=self._event_count,
|
|
274
|
+
_dropped_events=self._dropped_events,
|
|
275
|
+
_last_sequence=self._last_sequence,
|
|
276
|
+
_gap_ordinal=self._gap_ordinal,
|
|
277
|
+
_db=None,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
def snapshot(self) -> SessionStateSnapshot:
|
|
281
|
+
"""Create frozen snapshot for Phase 2/3."""
|
|
282
|
+
budget = BudgetSnapshot(
|
|
283
|
+
total_tool_calls=self._total_tool_calls,
|
|
284
|
+
total_tokens=self._total_tokens,
|
|
285
|
+
elapsed_seconds=self._elapsed_seconds,
|
|
286
|
+
by_effect=tuple(sorted(self._budget_counters["effect"].items())),
|
|
287
|
+
by_capability=tuple(sorted(self._budget_counters["capability"].items())),
|
|
288
|
+
by_scope=tuple(sorted(self._budget_counters["scope"].items())),
|
|
289
|
+
by_role=tuple(sorted(self._budget_counters["role"].items())),
|
|
290
|
+
by_phase=tuple(sorted(self._budget_counters["phase"].items())),
|
|
291
|
+
by_mechanism=tuple(sorted(self._budget_counters["mechanism"].items())),
|
|
292
|
+
by_action=tuple(sorted(self._budget_counters["action"].items())),
|
|
293
|
+
by_structure=tuple(sorted(self._budget_counters["structure"].items())),
|
|
294
|
+
pressure=self._pressure,
|
|
295
|
+
)
|
|
296
|
+
return SessionStateSnapshot(
|
|
297
|
+
budget=budget,
|
|
298
|
+
phase_window=tuple(self._phase_window),
|
|
299
|
+
taint_ledger=tuple(self._taint_ledger),
|
|
300
|
+
last_assistant_event_id=self._last_assistant_event_id,
|
|
301
|
+
last_user_event_id=self._last_user_event_id,
|
|
302
|
+
event_count=self._event_count,
|
|
303
|
+
dropped_events=self._dropped_events,
|
|
304
|
+
last_sequence=self._last_sequence,
|
|
305
|
+
gap_ordinal=self._gap_ordinal,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
def _write_state_rows(self) -> None:
|
|
309
|
+
"""Write every durable row for this session on the OPEN transaction.
|
|
310
|
+
|
|
311
|
+
Writes the ``session_state`` scalar row plus the normalized projections
|
|
312
|
+
(``budget_counters`` dimensional maps, ``taint_entries`` ledger). Does not
|
|
313
|
+
commit and does not lock — the caller owns both (either :meth:`persist`
|
|
314
|
+
under the writer lock, or the monitor's ``write_transaction``).
|
|
315
|
+
"""
|
|
316
|
+
assert self._db is not None # guarded by callers
|
|
317
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
318
|
+
self._db.execute(
|
|
319
|
+
"""INSERT OR REPLACE INTO session_state
|
|
320
|
+
(session_id, total_tool_calls, total_tokens, elapsed_seconds, pressure,
|
|
321
|
+
phase_window_json, last_assistant_json, last_user_json,
|
|
322
|
+
event_count, dropped_events, last_sequence, last_event_id, updated_at)
|
|
323
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
|
324
|
+
(
|
|
325
|
+
self.session_id,
|
|
326
|
+
self._total_tool_calls,
|
|
327
|
+
self._total_tokens,
|
|
328
|
+
self._elapsed_seconds,
|
|
329
|
+
int(self._pressure),
|
|
330
|
+
json.dumps(self._phase_window),
|
|
331
|
+
self._last_assistant_event_id,
|
|
332
|
+
self._last_user_event_id,
|
|
333
|
+
self._event_count,
|
|
334
|
+
self._dropped_events,
|
|
335
|
+
self._last_sequence,
|
|
336
|
+
None,
|
|
337
|
+
now,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
# Normalized budget counters: full rewrite mirrors the write-through
|
|
341
|
+
# semantics of INSERT OR REPLACE above (the in-memory Counters are the
|
|
342
|
+
# single source of truth for this session).
|
|
343
|
+
self._db.execute("DELETE FROM budget_counters WHERE session_id = ?", (self.session_id,))
|
|
344
|
+
for dimension, counter in self._budget_counters.items():
|
|
345
|
+
for key, count in counter.items():
|
|
346
|
+
self._db.execute(
|
|
347
|
+
"INSERT INTO budget_counters (session_id, dimension, key, count) "
|
|
348
|
+
"VALUES (?, ?, ?, ?)",
|
|
349
|
+
(self.session_id, dimension, key, count),
|
|
350
|
+
)
|
|
351
|
+
# Normalized taint ledger: ordinal preserves append order so the bounded
|
|
352
|
+
# ring-buffer trim behaves identically after a reload.
|
|
353
|
+
self._db.execute("DELETE FROM taint_entries WHERE session_id = ?", (self.session_id,))
|
|
354
|
+
for ordinal, t in enumerate(self._taint_ledger):
|
|
355
|
+
self._db.execute(
|
|
356
|
+
"INSERT INTO taint_entries (session_id, ordinal, event_id, source_event_key, "
|
|
357
|
+
"clearance, source, payload_pointer) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
|
358
|
+
(
|
|
359
|
+
self.session_id,
|
|
360
|
+
ordinal,
|
|
361
|
+
t.event_id,
|
|
362
|
+
t.source_event_key,
|
|
363
|
+
t.clearance,
|
|
364
|
+
t.source,
|
|
365
|
+
t.payload_pointer,
|
|
366
|
+
),
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
def persist(self) -> None:
|
|
370
|
+
"""Write-through to SQLite (self-committing, single-writer safe).
|
|
371
|
+
|
|
372
|
+
Acquires the store's writer lock (when one was attached) for the whole
|
|
373
|
+
transaction, so a standalone persist — e.g. the emitter's drop-recording
|
|
374
|
+
path — is serialized against the monitor's ``write_transaction`` on the
|
|
375
|
+
shared connection instead of racing it.
|
|
376
|
+
"""
|
|
377
|
+
if self._db is None:
|
|
378
|
+
return
|
|
379
|
+
with self._lock if self._lock is not None else nullcontext():
|
|
380
|
+
self._write_state_rows()
|
|
381
|
+
self._db.commit()
|
|
382
|
+
|
|
383
|
+
def persist_no_commit(self) -> None:
|
|
384
|
+
"""Write state to SQLite WITHOUT committing — caller must commit.
|
|
385
|
+
|
|
386
|
+
Used for atomic state+reservation transactions; the caller (the monitor's
|
|
387
|
+
``write_transaction``) already holds the writer lock, so this must not lock
|
|
388
|
+
or commit.
|
|
389
|
+
"""
|
|
390
|
+
if self._db is None:
|
|
391
|
+
return
|
|
392
|
+
self._write_state_rows()
|
|
393
|
+
|
|
394
|
+
@staticmethod
|
|
395
|
+
def _read_budget_counters(db: sqlite3.Connection, session_id: str) -> dict[str, dict[str, int]]:
|
|
396
|
+
"""Read the normalized dimensional counters as ``{dimension: {key: count}}``."""
|
|
397
|
+
rows = db.execute(
|
|
398
|
+
"SELECT dimension, key, count FROM budget_counters WHERE session_id = ?",
|
|
399
|
+
(session_id,),
|
|
400
|
+
).fetchall()
|
|
401
|
+
counters: dict[str, dict[str, int]] = {}
|
|
402
|
+
for dimension, key, count in rows:
|
|
403
|
+
counters.setdefault(dimension, {})[key] = count
|
|
404
|
+
return counters
|
|
405
|
+
|
|
406
|
+
@staticmethod
|
|
407
|
+
def _read_taint_entries(db: sqlite3.Connection, session_id: str) -> list["TaintEntry"]:
|
|
408
|
+
"""Read the normalized taint ledger, ordered by append ordinal."""
|
|
409
|
+
rows = db.execute(
|
|
410
|
+
"SELECT event_id, source_event_key, clearance, source, payload_pointer "
|
|
411
|
+
"FROM taint_entries WHERE session_id = ? ORDER BY ordinal",
|
|
412
|
+
(session_id,),
|
|
413
|
+
).fetchall()
|
|
414
|
+
return [
|
|
415
|
+
TaintEntry(
|
|
416
|
+
event_id=r[0],
|
|
417
|
+
source_event_key=r[1],
|
|
418
|
+
clearance=r[2],
|
|
419
|
+
source=r[3],
|
|
420
|
+
payload_pointer=r[4],
|
|
421
|
+
)
|
|
422
|
+
for r in rows
|
|
423
|
+
]
|
|
424
|
+
|
|
425
|
+
@classmethod
|
|
426
|
+
def load_from_db(
|
|
427
|
+
cls,
|
|
428
|
+
session_id: str,
|
|
429
|
+
db: sqlite3.Connection,
|
|
430
|
+
lock: "threading.Lock | None" = None,
|
|
431
|
+
) -> "SessionState":
|
|
432
|
+
"""Load state from SQLite on restart.
|
|
433
|
+
|
|
434
|
+
SQLite is authoritative: the atomic budget scalars are read from
|
|
435
|
+
``session_state`` columns, the dimensional counters from
|
|
436
|
+
``budget_counters``, and the taint ledger from ``taint_entries``. The
|
|
437
|
+
whole read runs under the writer lock (when provided) for a consistent
|
|
438
|
+
snapshot against a concurrent ``write_transaction``.
|
|
439
|
+
"""
|
|
440
|
+
state = cls(session_id=session_id)
|
|
441
|
+
state.attach_db(db, lock)
|
|
442
|
+
with lock if lock is not None else nullcontext():
|
|
443
|
+
row = db.execute(
|
|
444
|
+
"""SELECT total_tool_calls, total_tokens, elapsed_seconds, pressure,
|
|
445
|
+
phase_window_json, last_assistant_json, last_user_json,
|
|
446
|
+
event_count, dropped_events, last_sequence
|
|
447
|
+
FROM session_state WHERE session_id = ?""",
|
|
448
|
+
(session_id,),
|
|
449
|
+
).fetchone()
|
|
450
|
+
if row is None:
|
|
451
|
+
return state
|
|
452
|
+
state._total_tool_calls = row[0] or 0
|
|
453
|
+
state._total_tokens = row[1] or 0
|
|
454
|
+
state._elapsed_seconds = row[2] or 0.0
|
|
455
|
+
state._pressure = bool(row[3])
|
|
456
|
+
counters = cls._read_budget_counters(db, session_id)
|
|
457
|
+
for dim in _BUDGET_DIMENSIONS:
|
|
458
|
+
state._budget_counters[dim] = Counter(counters.get(dim, {}))
|
|
459
|
+
state._phase_window = json.loads(row[4]) if row[4] else []
|
|
460
|
+
state._last_assistant_event_id = row[5]
|
|
461
|
+
state._last_user_event_id = row[6]
|
|
462
|
+
state._taint_ledger = cls._read_taint_entries(db, session_id)
|
|
463
|
+
state._event_count = row[7] or 0
|
|
464
|
+
state._dropped_events = row[8] or 0
|
|
465
|
+
state._last_sequence = row[9]
|
|
466
|
+
return state
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Foundational immutable types for governance enrichment."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from typing import TYPE_CHECKING, Literal
|
|
10
|
+
|
|
11
|
+
from traceforge.classify.core import Classification
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from traceforge.governance.state import SessionStateSnapshot
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _normalize_timestamp(source_timestamp: datetime | str) -> str:
|
|
18
|
+
if isinstance(source_timestamp, datetime):
|
|
19
|
+
return source_timestamp.isoformat()
|
|
20
|
+
return source_timestamp
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hash_json_payload(payload: dict[str, str]) -> str:
|
|
24
|
+
canonical = json.dumps(payload, sort_keys=True)
|
|
25
|
+
return hashlib.sha256(canonical.encode()).hexdigest()
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def compute_source_event_key(
|
|
29
|
+
*,
|
|
30
|
+
session_id: str,
|
|
31
|
+
source_timestamp: datetime | str | None = None,
|
|
32
|
+
source_framework: str | None = None,
|
|
33
|
+
raw_event_id: str | None = None,
|
|
34
|
+
tool_name: str | None = None,
|
|
35
|
+
payload_hash: str | None = None,
|
|
36
|
+
event_kind: str | None = None,
|
|
37
|
+
) -> str:
|
|
38
|
+
"""Compute the stable idempotency key for a source event.
|
|
39
|
+
|
|
40
|
+
Lifecycle events use only ``session_id`` and ``event_kind`` so retries with
|
|
41
|
+
different adapter timestamps map to the same start/end event.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
if event_kind is not None:
|
|
45
|
+
return f"lifecycle:{session_id}:{event_kind}"
|
|
46
|
+
|
|
47
|
+
if raw_event_id is not None:
|
|
48
|
+
if source_framework is None or source_timestamp is None:
|
|
49
|
+
raise ValueError(
|
|
50
|
+
"source_framework and source_timestamp are required when raw_event_id is set"
|
|
51
|
+
)
|
|
52
|
+
return _hash_json_payload(
|
|
53
|
+
{
|
|
54
|
+
"session_id": session_id,
|
|
55
|
+
"source_framework": source_framework,
|
|
56
|
+
"raw_event_id": raw_event_id,
|
|
57
|
+
"source_timestamp": _normalize_timestamp(source_timestamp),
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
if tool_name is None or source_timestamp is None or payload_hash is None:
|
|
62
|
+
raise ValueError(
|
|
63
|
+
"tool_name, source_timestamp, and payload_hash are required when raw_event_id is absent"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
return _hash_json_payload(
|
|
67
|
+
{
|
|
68
|
+
"session_id": session_id,
|
|
69
|
+
"tool_name": tool_name,
|
|
70
|
+
"source_timestamp": _normalize_timestamp(source_timestamp),
|
|
71
|
+
"payload_hash": payload_hash,
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class SessionEvent:
|
|
78
|
+
"""Base for all pipeline events."""
|
|
79
|
+
|
|
80
|
+
event_id: str
|
|
81
|
+
session_id: str
|
|
82
|
+
timestamp: datetime
|
|
83
|
+
source_event_key: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True)
|
|
87
|
+
class ToolCallEvent(SessionEvent):
|
|
88
|
+
"""Pre-tool-call event emitted before invocation."""
|
|
89
|
+
|
|
90
|
+
span_id: str
|
|
91
|
+
tool_name: str
|
|
92
|
+
server_namespace: str | None
|
|
93
|
+
tool_args_json: str
|
|
94
|
+
source_event_id: str | None
|
|
95
|
+
mcp_server_name: str | None = None
|
|
96
|
+
tool_description: str | None = None
|
|
97
|
+
tool_schema_json: str | None = None
|
|
98
|
+
|
|
99
|
+
@classmethod
|
|
100
|
+
def from_dict(cls, payload: dict) -> "ToolCallEvent":
|
|
101
|
+
"""Build from a raw event dict (any intake channel)."""
|
|
102
|
+
import json as _json
|
|
103
|
+
import uuid as _uuid
|
|
104
|
+
from datetime import datetime as _dt
|
|
105
|
+
from datetime import timezone as _tz
|
|
106
|
+
|
|
107
|
+
if not isinstance(payload, dict):
|
|
108
|
+
payload = {}
|
|
109
|
+
tool_name = str(payload.get("tool_name", "") or "")
|
|
110
|
+
session_id = str(payload.get("session_id", "") or "") or f"anon-{_uuid.uuid4().hex[:8]}"
|
|
111
|
+
tool_input = (
|
|
112
|
+
payload.get("tool_input") if isinstance(payload.get("tool_input"), dict) else {}
|
|
113
|
+
)
|
|
114
|
+
eid = f"score-{_uuid.uuid4().hex[:12]}"
|
|
115
|
+
|
|
116
|
+
return cls(
|
|
117
|
+
event_id=eid,
|
|
118
|
+
session_id=session_id,
|
|
119
|
+
timestamp=_dt.now(_tz.utc),
|
|
120
|
+
source_event_key=f"score:{eid}",
|
|
121
|
+
span_id=f"score-span-{_uuid.uuid4().hex[:8]}",
|
|
122
|
+
tool_name=tool_name,
|
|
123
|
+
server_namespace=payload.get("server_namespace"),
|
|
124
|
+
tool_args_json=_json.dumps(tool_input, default=str),
|
|
125
|
+
source_event_id=None,
|
|
126
|
+
mcp_server_name=payload.get("mcp_server_name") or payload.get("server_namespace"),
|
|
127
|
+
tool_description=payload.get("tool_description"),
|
|
128
|
+
tool_schema_json=payload.get("tool_schema_json"),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class ToolResultEvent(SessionEvent):
|
|
134
|
+
"""Post-tool-call event emitted after invocation completes."""
|
|
135
|
+
|
|
136
|
+
span_id: str
|
|
137
|
+
tool_name: str
|
|
138
|
+
server_namespace: str | None
|
|
139
|
+
result_payload_json: str | None
|
|
140
|
+
result_status: Literal["success", "error", "timeout"]
|
|
141
|
+
pre_call_event_id: str
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class PipeSegment:
|
|
146
|
+
"""Single segment in a shell pipeline."""
|
|
147
|
+
|
|
148
|
+
binary: str
|
|
149
|
+
flags: tuple[str, ...]
|
|
150
|
+
targets: tuple[str, ...]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass(frozen=True)
|
|
154
|
+
class CommandAnalysis:
|
|
155
|
+
"""Immutable command details preserved for risk scoring."""
|
|
156
|
+
|
|
157
|
+
command: str | None
|
|
158
|
+
binary: str
|
|
159
|
+
flags: tuple[str, ...]
|
|
160
|
+
targets: tuple[str, ...]
|
|
161
|
+
pipe_segments: tuple[PipeSegment, ...] | None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass(frozen=True)
|
|
165
|
+
class EnrichmentContext:
|
|
166
|
+
"""Read-only inputs available to governance labeling."""
|
|
167
|
+
|
|
168
|
+
event: SessionEvent
|
|
169
|
+
base_classification: Classification
|
|
170
|
+
command_analysis: CommandAnalysis | None
|
|
171
|
+
session_state: "SessionStateSnapshot | None"
|
|
172
|
+
mcp_profiles: dict[str, dict] | None
|
|
173
|
+
project_root: str | None
|
|
174
|
+
engine: Literal["shell", "mcp", "coding"]
|
|
175
|
+
drift_baseline: tuple[tuple[str, float], ...] | None
|
|
176
|
+
mcp_profile_key: str | None
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""YAML framework mapping files for MappedJsonAdapter."""
|