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.
Files changed (205) hide show
  1. traceforge/__init__.py +72 -0
  2. traceforge/__main__.py +5 -0
  3. traceforge/_generated.py +254 -0
  4. traceforge/adapters/__init__.py +12 -0
  5. traceforge/adapters/base.py +82 -0
  6. traceforge/adapters/genai_otel.py +164 -0
  7. traceforge/adapters/mapped_json.py +297 -0
  8. traceforge/adapters/otel.py +220 -0
  9. traceforge/boundary/__init__.py +46 -0
  10. traceforge/boundary/data/boundary-model.joblib +0 -0
  11. traceforge/boundary/decode.py +87 -0
  12. traceforge/boundary/features.py +137 -0
  13. traceforge/boundary/inference.py +267 -0
  14. traceforge/boundary/inferencer.py +146 -0
  15. traceforge/classify/__init__.py +95 -0
  16. traceforge/classify/cmd.py +109 -0
  17. traceforge/classify/coding.py +213 -0
  18. traceforge/classify/config.py +554 -0
  19. traceforge/classify/core.py +266 -0
  20. traceforge/classify/data/binary_info.yaml +358 -0
  21. traceforge/classify/data/canonical_tools.yaml +251 -0
  22. traceforge/classify/data/effect_overrides.yaml +480 -0
  23. traceforge/classify/data/mcp_profiles.yaml +711 -0
  24. traceforge/classify/data/recommendation_rules.yaml +111 -0
  25. traceforge/classify/data/risk.yaml +534 -0
  26. traceforge/classify/data/shell_defaults.yaml +95 -0
  27. traceforge/classify/data/shell_rules.yaml +1192 -0
  28. traceforge/classify/data/tool_classifications.yaml +215 -0
  29. traceforge/classify/data/verb_inference.yaml +218 -0
  30. traceforge/classify/mcp.py +181 -0
  31. traceforge/classify/phases.py +75 -0
  32. traceforge/classify/powershell.py +93 -0
  33. traceforge/classify/registry.py +138 -0
  34. traceforge/classify/risk.py +553 -0
  35. traceforge/classify/rules.py +215 -0
  36. traceforge/classify/schema.yaml +282 -0
  37. traceforge/classify/shell.py +503 -0
  38. traceforge/classify/tools.py +91 -0
  39. traceforge/classify/workflow.py +32 -0
  40. traceforge/cli/__init__.py +42 -0
  41. traceforge/cli/config_cmd.py +130 -0
  42. traceforge/cli/detect.py +46 -0
  43. traceforge/cli/download_cmd.py +74 -0
  44. traceforge/cli/factory.py +60 -0
  45. traceforge/cli/gate_cmd.py +30 -0
  46. traceforge/cli/init_cmd.py +86 -0
  47. traceforge/cli/replay.py +130 -0
  48. traceforge/cli/runner.py +181 -0
  49. traceforge/cli/score.py +217 -0
  50. traceforge/cli/status.py +65 -0
  51. traceforge/cli/watch.py +297 -0
  52. traceforge/config/__init__.py +80 -0
  53. traceforge/config/defaults.py +149 -0
  54. traceforge/config/loader.py +239 -0
  55. traceforge/config/mappings.py +104 -0
  56. traceforge/config/models.py +579 -0
  57. traceforge/enricher.py +576 -0
  58. traceforge/formatting/__init__.py +12 -0
  59. traceforge/formatting/budget.py +92 -0
  60. traceforge/formatting/density.py +154 -0
  61. traceforge/gate/__init__.py +17 -0
  62. traceforge/gate/client.py +145 -0
  63. traceforge/gate/external.py +305 -0
  64. traceforge/gate/registry.py +108 -0
  65. traceforge/gate/server.py +174 -0
  66. traceforge/gates/__init__.py +8 -0
  67. traceforge/gates/pii.py +352 -0
  68. traceforge/gates/pii_patterns.yaml +228 -0
  69. traceforge/governance/__init__.py +121 -0
  70. traceforge/governance/assessor.py +441 -0
  71. traceforge/governance/budget.py +59 -0
  72. traceforge/governance/canonical.py +56 -0
  73. traceforge/governance/codec.py +414 -0
  74. traceforge/governance/context.py +249 -0
  75. traceforge/governance/drift.py +196 -0
  76. traceforge/governance/emitter.py +234 -0
  77. traceforge/governance/envelope.py +138 -0
  78. traceforge/governance/ifc.py +364 -0
  79. traceforge/governance/integrity.py +162 -0
  80. traceforge/governance/labeler.py +250 -0
  81. traceforge/governance/mcp_drift.py +328 -0
  82. traceforge/governance/monitor.py +539 -0
  83. traceforge/governance/observer.py +276 -0
  84. traceforge/governance/persistence.py +401 -0
  85. traceforge/governance/phase1.py +77 -0
  86. traceforge/governance/pii.py +276 -0
  87. traceforge/governance/pipeline.py +840 -0
  88. traceforge/governance/registry.py +110 -0
  89. traceforge/governance/results.py +183 -0
  90. traceforge/governance/risk_wrapper.py +113 -0
  91. traceforge/governance/rules.py +368 -0
  92. traceforge/governance/scorer.py +262 -0
  93. traceforge/governance/shield.py +318 -0
  94. traceforge/governance/state.py +466 -0
  95. traceforge/governance/types.py +176 -0
  96. traceforge/mappings/__init__.py +1 -0
  97. traceforge/mappings/aider.yaml +216 -0
  98. traceforge/mappings/aider_markdown.yaml +113 -0
  99. traceforge/mappings/amazonq.yaml +56 -0
  100. traceforge/mappings/antigravity.yaml +88 -0
  101. traceforge/mappings/claude.yaml +93 -0
  102. traceforge/mappings/cline.yaml +286 -0
  103. traceforge/mappings/codex.yaml +158 -0
  104. traceforge/mappings/continue_dev.yaml +57 -0
  105. traceforge/mappings/copilot.yaml +266 -0
  106. traceforge/mappings/copilot_markdown.yaml +72 -0
  107. traceforge/mappings/copilot_vscode.yaml +181 -0
  108. traceforge/mappings/crewai.yaml +592 -0
  109. traceforge/mappings/goose.yaml +128 -0
  110. traceforge/mappings/langgraph.yaml +165 -0
  111. traceforge/mappings/maf.yaml +90 -0
  112. traceforge/mappings/maf_transcript.yaml +99 -0
  113. traceforge/mappings/openai_agents.yaml +144 -0
  114. traceforge/mappings/opencode.yaml +473 -0
  115. traceforge/mappings/openhands.yaml +320 -0
  116. traceforge/mappings/pydantic_ai.yaml +183 -0
  117. traceforge/mappings/smolagents.yaml +89 -0
  118. traceforge/mappings/sweagent.yaml +82 -0
  119. traceforge/migrations/__init__.py +1 -0
  120. traceforge/migrations/env.py +60 -0
  121. traceforge/migrations/models.py +145 -0
  122. traceforge/migrations/runner.py +44 -0
  123. traceforge/migrations/script.py.mako +25 -0
  124. traceforge/migrations/versions/0001_initial.py +176 -0
  125. traceforge/migrations/versions/__init__.py +4 -0
  126. traceforge/models.py +29 -0
  127. traceforge/parsers/__init__.py +21 -0
  128. traceforge/parsers/aider.py +416 -0
  129. traceforge/parsers/base.py +226 -0
  130. traceforge/parsers/copilot.py +314 -0
  131. traceforge/phase/__init__.py +50 -0
  132. traceforge/phase/data/phase-model.joblib +0 -0
  133. traceforge/phase/data/potion-base-8M/README.md +99 -0
  134. traceforge/phase/data/potion-base-8M/config.json +13 -0
  135. traceforge/phase/data/potion-base-8M/model.safetensors +0 -0
  136. traceforge/phase/data/potion-base-8M/modules.json +14 -0
  137. traceforge/phase/data/potion-base-8M/tokenizer.json +1 -0
  138. traceforge/phase/event_rows.py +107 -0
  139. traceforge/phase/features.py +468 -0
  140. traceforge/phase/inference.py +279 -0
  141. traceforge/phase/inferencer.py +171 -0
  142. traceforge/phase/segmentation.py +258 -0
  143. traceforge/pipeline.py +891 -0
  144. traceforge/preprocessors/__init__.py +34 -0
  145. traceforge/preprocessors/amazonq.py +224 -0
  146. traceforge/preprocessors/antigravity.py +116 -0
  147. traceforge/preprocessors/claude.py +95 -0
  148. traceforge/preprocessors/cline.py +36 -0
  149. traceforge/preprocessors/codex.py +311 -0
  150. traceforge/preprocessors/continue_dev.py +119 -0
  151. traceforge/preprocessors/copilot_vscode.py +171 -0
  152. traceforge/preprocessors/goose.py +156 -0
  153. traceforge/preprocessors/maf_transcript.py +84 -0
  154. traceforge/preprocessors/openai_agents.py +86 -0
  155. traceforge/preprocessors/opencode.py +85 -0
  156. traceforge/preprocessors/openhands.py +36 -0
  157. traceforge/preprocessors/pydantic_ai.py +62 -0
  158. traceforge/preprocessors/registry.py +24 -0
  159. traceforge/preprocessors/smolagents.py +90 -0
  160. traceforge/py.typed +0 -0
  161. traceforge/sdk/__init__.py +59 -0
  162. traceforge/sdk/gate_policy.py +63 -0
  163. traceforge/sdk/gate_types.py +140 -0
  164. traceforge/sdk/pipeline.py +265 -0
  165. traceforge/sdk/verdict.py +81 -0
  166. traceforge/sinks/__init__.py +23 -0
  167. traceforge/sinks/base.py +95 -0
  168. traceforge/sinks/callback.py +132 -0
  169. traceforge/sinks/console.py +112 -0
  170. traceforge/sinks/factory.py +94 -0
  171. traceforge/sinks/jsonl.py +125 -0
  172. traceforge/sinks/otel_exporter.py +212 -0
  173. traceforge/sinks/parquet.py +260 -0
  174. traceforge/sinks/s3.py +206 -0
  175. traceforge/sinks/sqlite_output.py +234 -0
  176. traceforge/sinks/webhook.py +136 -0
  177. traceforge/sources/__init__.py +18 -0
  178. traceforge/sources/auto_detect.py +173 -0
  179. traceforge/sources/base.py +45 -0
  180. traceforge/sources/file_poll.py +136 -0
  181. traceforge/sources/file_watch.py +221 -0
  182. traceforge/sources/http_poll.py +141 -0
  183. traceforge/sources/replay.py +63 -0
  184. traceforge/sources/sqlite.py +187 -0
  185. traceforge/sources/sse.py +198 -0
  186. traceforge/telemetry/__init__.py +192 -0
  187. traceforge/title/__init__.py +23 -0
  188. traceforge/title/_resolve.py +79 -0
  189. traceforge/title/context.py +295 -0
  190. traceforge/title/data/boilerplate_files.json +14 -0
  191. traceforge/title/heuristics.py +429 -0
  192. traceforge/title/hygiene.py +90 -0
  193. traceforge/title/inference.py +314 -0
  194. traceforge/title/inferencer.py +477 -0
  195. traceforge/title/naming.py +398 -0
  196. traceforge/trace.py +291 -0
  197. traceforge/tracking/__init__.py +30 -0
  198. traceforge/tracking/models.py +115 -0
  199. traceforge/tracking/phase_tracker.py +288 -0
  200. traceforge/types.py +315 -0
  201. traceforge_toolkit-0.1.0.dist-info/METADATA +188 -0
  202. traceforge_toolkit-0.1.0.dist-info/RECORD +205 -0
  203. traceforge_toolkit-0.1.0.dist-info/WHEEL +4 -0
  204. traceforge_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
  205. traceforge_toolkit-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,364 @@
1
+ """Information Flow Control (IFC) source labeling and taint tracking.
2
+
3
+ IFCChecker is the Phase-1 taint engine. It runs once per event (from
4
+ ``phase1.py`` via ``labeler.check_ifc``) and its durable effect is the taint
5
+ ledger it accumulates on the mutable :class:`SessionState`. Phase-2 labeling
6
+ (``labeler.py``) reads the resulting ledger snapshot to emit the ``ifc_violation``
7
+ structure label and ``ifc:{clearance}`` source labels on egress.
8
+
9
+ The ``src_labels`` set populated by :meth:`IFCChecker.check` is part of the
10
+ method's contract (and is asserted directly in unit tests); the live pipeline
11
+ discards it, so the ledger is the channel that reaches downstream consumers.
12
+
13
+ Model (deterministic, CPU-only):
14
+
15
+ * **Clearance lattice** ``PUBLIC < INTERNAL < CONFIDENTIAL < SECRET``.
16
+ * **Data clearance** — sensitivity of the data an event reads or carries,
17
+ inferred from sensitive file paths / extensions in tool args (call) or the
18
+ result payload (result). Recorded as taint at ``CONFIDENTIAL`` or above.
19
+ * **Tool clearance** — the registered ceiling of a tool, taken from its MCP
20
+ profile. When data the tool handles (accumulated ledger taint or the event's
21
+ own data) strictly dominates that ceiling, the flow violates the partial
22
+ order and is flagged as a clearance violation.
23
+ * **span_id propagation** — taint follows a span through the pre/post
24
+ (ToolCallEvent -> ToolResultEvent) chain: a result inherits the clearance of
25
+ a prior same-span taint (matched by span id, or by ``pre_call_event_id`` back
26
+ to the call's event id). The originating span id is carried on each taint via
27
+ ``TaintEntry.payload_pointer`` so lineage survives serialization.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+ from enum import StrEnum
34
+ from typing import TYPE_CHECKING
35
+
36
+ if TYPE_CHECKING:
37
+ from traceforge.governance.state import SessionState, TaintEntry
38
+ from traceforge.governance.types import EnrichmentContext, SessionEvent
39
+
40
+
41
+ class Clearance(StrEnum):
42
+ """IFC clearance levels (ordered from least to most privileged)."""
43
+
44
+ PUBLIC = "public"
45
+ INTERNAL = "internal"
46
+ CONFIDENTIAL = "confidential"
47
+ SECRET = "secret"
48
+
49
+
50
+ _CLEARANCE_ORDER = {c: i for i, c in enumerate(Clearance)}
51
+
52
+ # Clearance at or above which accessed/carried data is recorded as taint.
53
+ _TAINT_THRESHOLD = Clearance.CONFIDENTIAL
54
+
55
+ # Public constants for IFC label rules (referenced by spec)
56
+ SCOPE_TO_LABEL: dict[str, str] = {
57
+ "host": "ifc:host_access",
58
+ "network": "ifc:network_access",
59
+ "cloud": "ifc:cloud_access",
60
+ "sandbox": "ifc:sandboxed",
61
+ }
62
+
63
+ PATH_LABEL_RULES: dict[str, Clearance] = {
64
+ ".env": Clearance.SECRET,
65
+ ".env.local": Clearance.SECRET,
66
+ ".env.production": Clearance.SECRET,
67
+ "secrets.yaml": Clearance.SECRET,
68
+ "credentials.json": Clearance.SECRET,
69
+ ".npmrc": Clearance.CONFIDENTIAL,
70
+ ".pypirc": Clearance.CONFIDENTIAL,
71
+ "id_rsa": Clearance.SECRET,
72
+ ".ssh/config": Clearance.SECRET,
73
+ "kubeconfig": Clearance.SECRET,
74
+ }
75
+
76
+ # Source label assignment rules
77
+ _SENSITIVE_PATHS = frozenset(PATH_LABEL_RULES.keys())
78
+ _SENSITIVE_EXTENSIONS = frozenset({".pem", ".key", ".p12", ".pfx", ".jks"})
79
+
80
+ # Precompiled, boundary-aware patterns. Sensitive names must sit on a path
81
+ # separator or string boundary so substrings (e.g. "prevent") never match.
82
+ _PATH_PATTERNS: tuple[tuple[re.Pattern[str], Clearance], ...] = tuple(
83
+ (
84
+ re.compile(r'(?:^|[/\\\s"\':])' + re.escape(path) + r'(?:$|[/\\\s"\',})\]])'),
85
+ PATH_LABEL_RULES.get(path, Clearance.SECRET),
86
+ )
87
+ for path in _SENSITIVE_PATHS
88
+ )
89
+ _EXT_PATTERNS: tuple[tuple[re.Pattern[str], Clearance], ...] = tuple(
90
+ (re.compile(re.escape(ext) + r'(?:$|["\s,}\]\)])'), Clearance.CONFIDENTIAL)
91
+ for ext in _SENSITIVE_EXTENSIONS
92
+ )
93
+
94
+
95
+ def _rank(clearance: object) -> int:
96
+ """Lattice rank of a clearance value (accepts Clearance or its str value)."""
97
+ return _CLEARANCE_ORDER.get(clearance, -1) # type: ignore[arg-type]
98
+
99
+
100
+ def _higher(a: Clearance | None, b: Clearance | None) -> Clearance | None:
101
+ """Return the higher-ranked of two optional clearances."""
102
+ if a is None:
103
+ return b
104
+ if b is None:
105
+ return a
106
+ return a if _rank(a) >= _rank(b) else b
107
+
108
+
109
+ def _max_clearance(values) -> Clearance | str | None:
110
+ """Return the highest-ranked clearance in ``values`` (skipping ``None``).
111
+
112
+ Accepts a mix of :class:`Clearance` members and their raw string values
113
+ (ledger entries store the value as ``str``); the original object is returned
114
+ so callers can render it verbatim.
115
+ """
116
+ best: Clearance | str | None = None
117
+ best_rank = -1
118
+ for value in values:
119
+ if value is None:
120
+ continue
121
+ rank = _rank(value)
122
+ if rank > best_rank:
123
+ best_rank = rank
124
+ best = value
125
+ return best
126
+
127
+
128
+ def _dominates(higher: object, lower: object) -> bool:
129
+ """True when ``higher`` sits strictly above ``lower`` in the lattice."""
130
+ return _rank(higher) > _rank(lower)
131
+
132
+
133
+ class IFCChecker:
134
+ """Information Flow Control — assigns source labels and tracks taints."""
135
+
136
+ def check(
137
+ self,
138
+ ctx: "EnrichmentContext",
139
+ src_labels: set[str],
140
+ session_state: "SessionState",
141
+ ) -> None:
142
+ """Assign IFC labels and accumulate taint for a single event.
143
+
144
+ Populates ``src_labels`` (the method's contract) and records taint on
145
+ ``session_state`` via the bounded :meth:`SessionState.add_taint`, which
146
+ FIFO-evicts oldest entries past the ledger cap.
147
+ """
148
+ event = ctx.event
149
+ ledger = session_state.taint_ledger
150
+ span_id = self._span_id(event)
151
+
152
+ # 1) Inbound span propagation — inherit taint from a prior event on the
153
+ # same span (pre/post chain), so a result carries its call's taint.
154
+ inherited = self._inherited_span_clearance(event, ledger)
155
+ if inherited is not None:
156
+ src_labels.add(f"ifc:tainted_span:{inherited}")
157
+
158
+ # 2) Egress of accumulated taint: a mutating/destructive action while
159
+ # the session already carries taint is an outbound flow.
160
+ if ledger and ctx.base_classification.effect in ("mutating", "destructive"):
161
+ accumulated = _max_clearance(t.clearance for t in ledger)
162
+ if accumulated is not None:
163
+ src_labels.add(f"ifc:tainted_write:{accumulated}")
164
+
165
+ # 3) Clearance of the data this event reads or carries, combined with
166
+ # any clearance inherited along its span.
167
+ data_clearance = self._infer_data_clearance(ctx)
168
+ effective = _max_clearance((data_clearance, inherited))
169
+
170
+ # 4) Record taint when the effective clearance is CONFIDENTIAL or above.
171
+ recorded = False
172
+ if effective is not None and _rank(effective) >= _rank(_TAINT_THRESHOLD):
173
+ src_labels.add(f"ifc:{effective}")
174
+ self._record_taint(
175
+ session_state,
176
+ event,
177
+ clearance=effective,
178
+ source=self._classify_source(ctx),
179
+ span_id=span_id,
180
+ )
181
+ recorded = True
182
+
183
+ # 5) Tool-clearance partial-order violation: the tool's registered
184
+ # ceiling is dominated by data it is handling (accumulated or own).
185
+ self._check_tool_clearance(
186
+ ctx, src_labels, session_state, effective=effective, recorded=recorded
187
+ )
188
+
189
+ # ── clearance inference ────────────────────────────────────────────────
190
+
191
+ def _infer_data_clearance(self, ctx: "EnrichmentContext") -> Clearance | None:
192
+ """Clearance of the data an event reads (call args) or carries (result)."""
193
+ from traceforge.governance.types import ToolCallEvent, ToolResultEvent
194
+
195
+ event = ctx.event
196
+ if isinstance(event, ToolCallEvent):
197
+ return self._clearance_from_args(event.tool_args_json)
198
+ if isinstance(event, ToolResultEvent):
199
+ return self._scan_sensitive_text((event.result_payload_json or "").lower())
200
+ return None
201
+
202
+ def _clearance_from_args(self, tool_args_json: str) -> Clearance | None:
203
+ """Infer clearance from tool-call args (structured path + text scan)."""
204
+ import json as json_mod
205
+
206
+ structured: Clearance | None = None
207
+ try:
208
+ args_dict = json_mod.loads(tool_args_json)
209
+ file_path = (
210
+ args_dict.get("path") or args_dict.get("file") or args_dict.get("filename") or ""
211
+ )
212
+ if isinstance(file_path, str) and file_path:
213
+ structured = self._clearance_from_path(file_path.lower())
214
+ except (json_mod.JSONDecodeError, TypeError, AttributeError):
215
+ structured = None
216
+
217
+ return _higher(structured, self._scan_sensitive_text(tool_args_json.lower()))
218
+
219
+ def _clearance_from_path(self, path_lower: str) -> Clearance | None:
220
+ """Exact basename / suffix match against known sensitive files."""
221
+ basename = path_lower.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
222
+ best: Clearance | None = None
223
+ for sensitive_path in _SENSITIVE_PATHS:
224
+ if (
225
+ basename == sensitive_path
226
+ or path_lower.endswith(f"/{sensitive_path}")
227
+ or path_lower.endswith(f"\\{sensitive_path}")
228
+ ):
229
+ best = _higher(best, PATH_LABEL_RULES.get(sensitive_path, Clearance.SECRET))
230
+ if best is not None:
231
+ return best
232
+ for ext in _SENSITIVE_EXTENSIONS:
233
+ if basename.endswith(ext):
234
+ return Clearance.CONFIDENTIAL
235
+ return None
236
+
237
+ def _scan_sensitive_text(self, text_lower: str) -> Clearance | None:
238
+ """Boundary-aware scan for sensitive names/extensions in raw text.
239
+
240
+ Returns the highest-ranked clearance across all matches so the result is
241
+ independent of pattern iteration order.
242
+ """
243
+ if not text_lower:
244
+ return None
245
+ best: Clearance | None = None
246
+ for pattern, clearance in _PATH_PATTERNS:
247
+ if pattern.search(text_lower):
248
+ best = _higher(best, clearance)
249
+ if best is not None:
250
+ return best
251
+ for pattern, clearance in _EXT_PATTERNS:
252
+ if pattern.search(text_lower):
253
+ return clearance
254
+ return None
255
+
256
+ def _tool_clearance(self, ctx: "EnrichmentContext") -> Clearance | None:
257
+ """The current tool's registered clearance ceiling from its MCP profile."""
258
+ if not ctx.mcp_profiles:
259
+ return None
260
+ key = ctx.mcp_profile_key
261
+ if not key or key not in ctx.mcp_profiles:
262
+ return None
263
+ raw = ctx.mcp_profiles[key].get("clearance")
264
+ if not raw:
265
+ return None
266
+ try:
267
+ return Clearance(raw)
268
+ except ValueError:
269
+ # Unknown clearance value in MCP profile — treat as unbounded (no ceiling).
270
+ return None
271
+
272
+ # ── span lineage ───────────────────────────────────────────────────────
273
+
274
+ def _span_id(self, event: "SessionEvent") -> str | None:
275
+ """Span identifier for an event, when the event type carries one."""
276
+ span = getattr(event, "span_id", None)
277
+ return span if isinstance(span, str) and span else None
278
+
279
+ def _inherited_span_clearance(
280
+ self, event: "SessionEvent", ledger: list["TaintEntry"]
281
+ ) -> Clearance | str | None:
282
+ """Highest clearance already tainted on this event's span.
283
+
284
+ Matches prior taint by span id (carried in ``payload_pointer``) or, for a
285
+ result event, by ``pre_call_event_id`` back to the call's ``event_id``.
286
+ """
287
+ if not ledger:
288
+ return None
289
+ span_id = self._span_id(event)
290
+ pre_call_id = getattr(event, "pre_call_event_id", None)
291
+ matches = [
292
+ t.clearance
293
+ for t in ledger
294
+ if (span_id and t.payload_pointer == span_id)
295
+ or (pre_call_id and t.event_id == pre_call_id)
296
+ ]
297
+ return _max_clearance(matches)
298
+
299
+ # ── taint recording ────────────────────────────────────────────────────
300
+
301
+ def _check_tool_clearance(
302
+ self,
303
+ ctx: "EnrichmentContext",
304
+ src_labels: set[str],
305
+ session_state: "SessionState",
306
+ *,
307
+ effective: Clearance | str | None,
308
+ recorded: bool,
309
+ ) -> None:
310
+ """Flag a partial-order violation when data dominates the tool ceiling."""
311
+ ceiling = self._tool_clearance(ctx)
312
+ if ceiling is None:
313
+ return
314
+ candidates = [t.clearance for t in session_state.taint_ledger]
315
+ if effective is not None:
316
+ candidates.append(effective)
317
+ offending = _max_clearance(candidates)
318
+ if offending is None or not _dominates(offending, ceiling):
319
+ return
320
+ src_labels.add(f"ifc:ifc_violation:{offending}>{ceiling}")
321
+ # Persist the violation so a downstream egress sees a prior taint, unless
322
+ # this event already left an equivalent taint above.
323
+ if not recorded:
324
+ self._record_taint(
325
+ session_state,
326
+ ctx.event,
327
+ clearance=offending,
328
+ source="ifc_violation",
329
+ span_id=self._span_id(ctx.event),
330
+ )
331
+
332
+ def _record_taint(
333
+ self,
334
+ session_state: "SessionState",
335
+ event: "SessionEvent",
336
+ *,
337
+ clearance: Clearance | str,
338
+ source: str,
339
+ span_id: str | None,
340
+ ) -> None:
341
+ """Append a taint entry through the bounded, FIFO-evicting ledger."""
342
+ from traceforge.governance.state import TaintEntry
343
+
344
+ session_state.add_taint(
345
+ TaintEntry(
346
+ event_id=event.event_id,
347
+ source_event_key=event.source_event_key,
348
+ clearance=str(clearance),
349
+ source=source,
350
+ payload_pointer=span_id or "",
351
+ )
352
+ )
353
+
354
+ def _classify_source(self, ctx: "EnrichmentContext") -> str:
355
+ """Classify the source type of the data access."""
356
+ from traceforge.governance.types import ToolCallEvent, ToolResultEvent
357
+
358
+ if isinstance(ctx.event, ToolResultEvent):
359
+ return "tool_output"
360
+ if isinstance(ctx.event, ToolCallEvent):
361
+ args = ctx.event.tool_args_json.lower()
362
+ if "read" in args or "get" in args or "fetch" in args:
363
+ return "file_read"
364
+ return "tool_input"
@@ -0,0 +1,162 @@
1
+ """Content integrity verification via SHA-256 hashing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from traceforge.governance.persistence import SystemStore
11
+ from traceforge.governance.types import EnrichmentContext
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class IntegrityCheck:
16
+ """Result of verifying a file's content hash."""
17
+
18
+ path: str
19
+ expected_hash: str
20
+ actual_hash: str
21
+ matched: bool
22
+ last_known_writer: str | None
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class IntegrityWrite:
27
+ """A self-contained prescription to (re)baseline one file's content hash.
28
+
29
+ Emitted during side-effect-free labeling and committed later by the monitor's
30
+ finalization transaction (mirroring ``mcp_deferred_writes``). Carries everything
31
+ needed to persist the row so the writer never needs the verifier's ``repo``.
32
+ """
33
+
34
+ repo: str
35
+ path: str
36
+ sha256: str
37
+ session_id: str
38
+ timestamp: str
39
+
40
+
41
+ class IntegrityVerifier:
42
+ """Verifies content integrity by comparing SHA-256 hashes against stored baselines.
43
+
44
+ The repo key is derived **per event** from ``ctx.project_root`` (mirroring
45
+ ``drift.py``'s ``ctx.project_root or "unknown"``), not fixed at construction. A
46
+ single verifier therefore serves every session/repo a pipeline observes, so the
47
+ default composition can wire it unconditionally without a construction-time repo.
48
+ """
49
+
50
+ def __init__(self, store: "SystemStore") -> None:
51
+ self._store = store
52
+
53
+ @staticmethod
54
+ def _repo_for(ctx: "EnrichmentContext") -> str:
55
+ """Repo key for this event — matches drift.py's ``project_root or "unknown"``."""
56
+ return ctx.project_root or "unknown"
57
+
58
+ def should_check(self, classification: "object") -> bool:
59
+ """Whether to verify integrity for this classification."""
60
+ from traceforge.classify.core import Classification
61
+
62
+ if not isinstance(classification, Classification):
63
+ return False
64
+ return (
65
+ classification.effect in ("mutating", "destructive")
66
+ or "filesystem_write" in classification.capability
67
+ )
68
+
69
+ def check_event(self, ctx: "EnrichmentContext", cap: set[str]) -> None:
70
+ """High-level: check integrity for all file writes in event."""
71
+ if not self.should_check(ctx.base_classification):
72
+ return
73
+ repo = self._repo_for(ctx)
74
+ for path, content in self._extract_file_writes(ctx):
75
+ result = self.check(repo, path, content)
76
+ if result and not result.matched:
77
+ cap.add("integrity_unverified")
78
+
79
+ def check(self, repo: str, path: str, content: bytes) -> IntegrityCheck | None:
80
+ """Low-level: compare content hash against the stored value for ``repo``."""
81
+ expected = self._store.get_content_hash(repo, path)
82
+ if expected is None:
83
+ return None # Path not tracked
84
+
85
+ actual = hashlib.sha256(content).hexdigest()
86
+ # Get writer from content_hashes table
87
+ row = self._store.connection.execute(
88
+ "SELECT updated_by_session FROM content_hashes WHERE repo = ? AND file_path = ?",
89
+ (repo, path),
90
+ ).fetchone()
91
+ writer = row[0] if row else None
92
+
93
+ return IntegrityCheck(
94
+ path=path,
95
+ expected_hash=expected,
96
+ actual_hash=actual,
97
+ matched=(actual == expected),
98
+ last_known_writer=writer,
99
+ )
100
+
101
+ def record_write(
102
+ self, repo: str, path: str, content: bytes, session_id: str, timestamp: str
103
+ ) -> None:
104
+ """Record a new content hash after a successful write."""
105
+ sha = hashlib.sha256(content).hexdigest()
106
+ self._store.store_content_hash(repo, path, sha, session_id, timestamp)
107
+
108
+ def pending_writes(self, ctx: "EnrichmentContext") -> list[IntegrityWrite]:
109
+ """Prescriptions to (re)baseline this event's writes. Pure — no store mutation.
110
+
111
+ Gated by the same :meth:`should_check` as :meth:`check_event`, so only
112
+ mutating/destructive or ``filesystem_write`` events produce baselines. The
113
+ actual persistence is deferred to the monitor's finalization commit, which runs
114
+ *after* :meth:`check_event` has already compared against the prior baseline — so
115
+ drift (including cross-session drift) is detected before the baseline is updated.
116
+ """
117
+ if not self.should_check(ctx.base_classification):
118
+ return []
119
+ repo = self._repo_for(ctx)
120
+ ts = ctx.event.timestamp
121
+ timestamp = ts.isoformat() if hasattr(ts, "isoformat") else str(ts)
122
+ session_id = ctx.event.session_id
123
+ return [
124
+ IntegrityWrite(
125
+ repo=repo,
126
+ path=path,
127
+ sha256=hashlib.sha256(content).hexdigest(),
128
+ session_id=session_id,
129
+ timestamp=timestamp,
130
+ )
131
+ for path, content in self._extract_file_writes(ctx)
132
+ ]
133
+
134
+ def _extract_file_writes(self, ctx: "EnrichmentContext") -> list[tuple[str, bytes]]:
135
+ """Extract file paths and content from a write event."""
136
+ from traceforge.governance.types import ToolCallEvent
137
+ import json
138
+
139
+ writes: list[tuple[str, bytes]] = []
140
+ if not isinstance(ctx.event, ToolCallEvent):
141
+ return writes
142
+
143
+ try:
144
+ args = json.loads(ctx.event.tool_args_json)
145
+ except (json.JSONDecodeError, TypeError):
146
+ return writes
147
+
148
+ # Common patterns: path+content, file+data, filename+text
149
+ path = args.get("path") or args.get("file") or args.get("filename")
150
+ content = args.get("content") or args.get("data") or args.get("text")
151
+
152
+ if path and content:
153
+ if isinstance(content, str):
154
+ content = content.encode("utf-8")
155
+ elif isinstance(content, bytes):
156
+ pass # Already bytes
157
+ else:
158
+ # Non-string content (dict, list, int) — serialize deterministically
159
+ content = json.dumps(content, sort_keys=True, separators=(",", ":")).encode("utf-8")
160
+ writes.append((str(path), content))
161
+
162
+ return writes