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