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,110 @@
1
+ """Per-session residency for governance: the single owner of live SessionState.
2
+
3
+ A session has two distinct residency scopes that must never be conflated, because
4
+ the two governance channels have opposite durability and threading constraints:
5
+
6
+ * **Durable / observation residency** — owned by the ``SessionMonitor`` single
7
+ writer. States are rehydrated from the durable store, carry a live DB
8
+ connection, and their ``persist`` actually writes. Phase-1 mutations here
9
+ (budget, taint) must survive a restart.
10
+ * **Ephemeral / gate residency** — owned by the ``Shield`` enforcement gate,
11
+ which runs at the framework's input edge on arbitrary threads. Gate state is
12
+ created with ``_db=None`` and never touches SQLite, so it is safe to build
13
+ cross-thread; it only carries in-process gate context.
14
+
15
+ Keeping these separate is the fix for the F2 durability gap: when the gate creates
16
+ a session's state first, the writer must not be handed that non-durable
17
+ ``_db=None`` state (its persists would silently no-op). See :class:`SessionRegistry`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from typing import TYPE_CHECKING
23
+
24
+ if TYPE_CHECKING:
25
+ from traceforge.governance.persistence import SystemStore
26
+ from traceforge.governance.state import SessionState
27
+
28
+
29
+ class SessionRegistry:
30
+ """Owns per-session residency across two scopes: durable and gate-ephemeral.
31
+
32
+ Centralizing residency here means no two code paths create divergent state for
33
+ the same session. But the observation (writer) and gate channels have
34
+ incompatible needs, so each session keeps a state in whichever of two maps
35
+ matches the caller's channel — the writer never borrows the gate's state and
36
+ vice versa:
37
+
38
+ * ``_durable_states`` — the observation channel (single writer:
39
+ ``SessionMonitor``). ``get_or_create`` rehydrates from the durable store,
40
+ so entries are always DB-backed and their persists durably write.
41
+ * ``_gate_states`` — the gate channel (``Shield``). ``ensure`` creates
42
+ thread-safe ephemeral state (``_db=None``, never touching SQLite
43
+ cross-thread) for enforcement context.
44
+
45
+ Because the two channels hold *separate* ``SessionState`` objects per session,
46
+ their counters can never be mutated concurrently from different threads, and
47
+ the writer's ``persist_no_commit`` is guaranteed a live connection (the F2
48
+ durability invariant).
49
+ """
50
+
51
+ def __init__(self, store: "SystemStore") -> None:
52
+ self._store = store
53
+ # Observation channel: DB-backed states (single writer: SessionMonitor).
54
+ self._durable_states: dict[str, "SessionState"] = {}
55
+ # Gate channel: ephemeral _db=None states (Shield enforcement).
56
+ self._gate_states: dict[str, "SessionState"] = {}
57
+
58
+ # ── Durable / observation residency (the single writer) ──
59
+
60
+ def get_or_create(self, session_id: str) -> "SessionState":
61
+ """Return the durable, DB-backed state, rehydrating from the store on a miss.
62
+
63
+ Always yields a state with a live connection, so the writer's
64
+ ``persist_no_commit`` / ``persist`` durably write. Observation channel only.
65
+ """
66
+ from traceforge.governance.state import SessionState
67
+
68
+ if session_id not in self._durable_states:
69
+ self._durable_states[session_id] = SessionState.load_from_db(
70
+ session_id, self._store.connection, self._store.lock
71
+ )
72
+ return self._durable_states[session_id]
73
+
74
+ def evict(self, session_id: str) -> "SessionState | None":
75
+ """Remove and return the durable (observation) state for a session, if any."""
76
+ return self._durable_states.pop(session_id, None)
77
+
78
+ # ── Ephemeral / gate residency (enforcement, thread-safe) ──
79
+
80
+ def ensure(self, session_id: str) -> "SessionState":
81
+ """Return the gate's ephemeral state, creating it (``_db=None``) on a miss.
82
+
83
+ Never rehydrates from SQLite, so it is safe to call from any thread.
84
+ Uses ``setdefault`` so concurrent callers converge on one instance.
85
+ """
86
+ from traceforge.governance.state import SessionState
87
+
88
+ if session_id not in self._gate_states:
89
+ self._gate_states.setdefault(session_id, SessionState(session_id=session_id))
90
+ return self._gate_states[session_id]
91
+
92
+ def get_gate(self, session_id: str) -> "SessionState | None":
93
+ """Return the gate's resident ephemeral state, or None if not resident."""
94
+ return self._gate_states.get(session_id)
95
+
96
+ def evict_gate(self, session_id: str) -> "SessionState | None":
97
+ """Remove and return the gate's ephemeral state for a session, if any."""
98
+ return self._gate_states.pop(session_id, None)
99
+
100
+ # ── Read-only preview (Scorer) ──
101
+
102
+ def preview_state(self, session_id: str) -> "SessionState | None":
103
+ """Return a base state for a non-persisted preview, or None if unknown.
104
+
105
+ Prefers the durable observation state (so a preview reflects accumulated,
106
+ persisted budgets), falling back to the gate's ephemeral state. The caller
107
+ clones this via ``clone_detached`` and never mutates or persists it, so
108
+ returning either residency is side-effect-free.
109
+ """
110
+ return self._durable_states.get(session_id) or self._gate_states.get(session_id)
@@ -0,0 +1,183 @@
1
+ """Governance result types — strongly typed outputs from the enrichment pipeline.
2
+
3
+ These types are intentionally separated from pipeline.py to break the circular
4
+ import between traceforge.types (EventMetadata) and the governance layer.
5
+ Both types.py and pipeline.py import from here.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass, field
12
+ from datetime import datetime
13
+ from enum import StrEnum
14
+ from types import MappingProxyType
15
+
16
+ from traceforge.classify.core import Classification
17
+ from traceforge.classify.risk import RiskAssessment
18
+ from traceforge.governance.drift import DriftAssessment
19
+ from traceforge.governance.state import BudgetSnapshot
20
+
21
+
22
+ class RecommendedAction(StrEnum):
23
+ ALLOW = "allow"
24
+ WARN = "warn"
25
+ ESCALATE = "escalate"
26
+ DENY = "deny"
27
+ TRANSFORM = "transform"
28
+
29
+
30
+ def _empty_parameters() -> Mapping[str, str]:
31
+ """Default immutable (read-only) parameter mapping."""
32
+ return MappingProxyType({})
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class TransformSuggestion:
37
+ """Materialized by Phase 3 from TransformTemplate + event-specific data.
38
+
39
+ The transform is *advisory*: it describes what a safe alternative would look
40
+ like via a ``strategy`` and immutable ``parameters``, plus the resolved
41
+ ``original_value`` of ``target_field`` (``None`` when the field is absent).
42
+ traceforge never applies the transform.
43
+ """
44
+
45
+ target_kind: str # "shell_flag", "shell_arg", "tool_arg", "file_content", "field"
46
+ path: str # AST node path (shell) or JSONPath (mcp tool args)
47
+ original: str
48
+ replacement: str | None # None = suggest removal
49
+ rationale: str
50
+ confidence: str = "medium" # "high", "medium", "low"
51
+ target_field: str | None = None # dotted path resolved against event data
52
+ strategy: str = "pattern_replace" # how to transform: e.g. redact/remove/replace
53
+ parameters: Mapping[str, str] = field(default_factory=_empty_parameters)
54
+ original_value: object | None = None # resolved value of target_field; None if missing
55
+
56
+ def __post_init__(self) -> None:
57
+ # Freeze parameters into a read-only copy so callers cannot mutate them
58
+ # after construction (frozen=True only guards attribute rebinding).
59
+ object.__setattr__(self, "parameters", MappingProxyType(dict(self.parameters)))
60
+
61
+ def __hash__(self) -> int:
62
+ # Hashable despite the mapping field. original_value is excluded so the
63
+ # suggestion stays hashable even when it resolves to a container; this is
64
+ # consistent with __eq__ (equal objects share all fields -> equal hashes).
65
+ return hash(
66
+ (
67
+ self.target_kind,
68
+ self.path,
69
+ self.original,
70
+ self.replacement,
71
+ self.rationale,
72
+ self.confidence,
73
+ self.target_field,
74
+ self.strategy,
75
+ frozenset(self.parameters.items()),
76
+ )
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class EscalationContext:
82
+ """Rich metadata for escalate/deny — full classification context."""
83
+
84
+ canonical_id: str
85
+ classification: "Classification"
86
+ recommended_action: "RecommendedAction"
87
+ reason_code: str
88
+ mitre_techniques: tuple[str, ...]
89
+ drift: "DriftAssessment | None"
90
+ budget_snapshot: "BudgetSnapshot | None"
91
+ pii_taint: bool
92
+ ifc_violations: int
93
+ tool_name: str
94
+ tool_args_summary: str # Sanitized — no secrets
95
+ session_id: str
96
+ timestamp: datetime
97
+ # --- #24: richer escalation context ---
98
+ event_id: str = "" # Triggering event's id (ctx.event.event_id)
99
+ classification_summary: str = "" # Concise deterministic human-readable summary
100
+ risk_factors: tuple[str, ...] = () # risk.factors that drove the score
101
+ session_event_count: int = 0 # snapshot.event_count at escalation time
102
+ recent_phase_window: tuple[str, ...] = () # snapshot.phase_window (recent phases)
103
+
104
+
105
+ @dataclass(frozen=True)
106
+ class EvidencePointer:
107
+ """What triggered this evidence."""
108
+
109
+ event_id: str
110
+ rule_id: str
111
+ detector: str
112
+ payload_pointer: str | None = None
113
+
114
+
115
+ @dataclass(frozen=True)
116
+ class Evidence:
117
+ """Emitted for warn/escalate/deny recommendations."""
118
+
119
+ canonical_id: str
120
+ timestamp: datetime
121
+ session_id: str
122
+ mechanism: str
123
+ effect: str | None
124
+ scope: tuple[str, ...]
125
+ role: tuple[str, ...]
126
+ action: tuple[str, ...]
127
+ capability: tuple[str, ...]
128
+ structure: tuple[str, ...]
129
+ source_labels: tuple[str, ...]
130
+ recommended_action: RecommendedAction
131
+ risk_score: int
132
+ risk_factors: tuple[str, ...]
133
+ mitre_techniques: tuple[str, ...]
134
+ pointers: tuple[EvidencePointer, ...]
135
+ escalation: EscalationContext | None = None
136
+ # --- #25: promote rule provenance + matched-predicate detail to the top level ---
137
+ rule_id: str = "" # Matching rule id (also carried on each EvidencePointer)
138
+ matched_predicates: tuple[str, ...] = () # Serialized rule predicates that matched
139
+
140
+
141
+ @dataclass(frozen=True)
142
+ class RiskRecommendation:
143
+ """Full recommendation with canonical identity."""
144
+
145
+ recommended_action: RecommendedAction
146
+ assessment: "RiskAssessment"
147
+ reason_code: str
148
+ canonical_id: str
149
+ message: str | None = None
150
+ transform: TransformSuggestion | None = None
151
+
152
+
153
+ @dataclass(frozen=True)
154
+ class RecommendationResult:
155
+ """Phase 3 output envelope."""
156
+
157
+ recommendation: RiskRecommendation
158
+ evidence: Evidence | None = None
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class Phase3Result:
163
+ """Always produced by Phase 3."""
164
+
165
+ risk_assessment: "RiskAssessment"
166
+ recommendation_result: RecommendationResult | None = None
167
+
168
+
169
+ @dataclass(frozen=True)
170
+ class SessionMeta:
171
+ """Full classification output. Attached to event metadata as `governance`.
172
+
173
+ For lifecycle events (session_start/end), Phase 2/3 fields are None.
174
+ canonical_id is accessed via recommendation.canonical_id (no separate field).
175
+ """
176
+
177
+ classification: "Classification | None"
178
+ risk_assessment: "RiskAssessment | None"
179
+ recommendation: RiskRecommendation | None = None
180
+ budget_snapshot: "BudgetSnapshot | None" = None
181
+ drift: "DriftAssessment | None" = None
182
+ mcp_alerts: tuple = () # tuple[MCPIntegrityAlert, ...]
183
+ evidence: Evidence | None = None
@@ -0,0 +1,113 @@
1
+ """Governance risk assessment wrapper around existing assess_risk()."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import dataclasses
6
+ from dataclasses import dataclass
7
+ from typing import TYPE_CHECKING
8
+
9
+ if TYPE_CHECKING:
10
+ from traceforge.classify.config import ClassificationEngine
11
+ from traceforge.classify.core import Classification
12
+ from traceforge.classify.risk import RiskAssessment
13
+ from traceforge.governance.types import CommandAnalysis
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class RiskModifiers:
18
+ """Governance-specific risk score modifiers (additive bonuses)."""
19
+
20
+ phase_drift_bonus: int = 0 # +10 per anomaly up to 20
21
+ mcp_drift_bonus: int = 0 # +15 per schema change
22
+ ifc_violations: int = 0 # +10 per violation up to 30
23
+ integrity_bonus: int = 0 # +10 if integrity_unverified
24
+ budget_bonus: int = 0 # +5 if budget pressure
25
+
26
+
27
+ def assess_governance_risk(
28
+ enriched_classification: "Classification",
29
+ command_analysis: "CommandAnalysis | None",
30
+ risk_modifiers: RiskModifiers,
31
+ *,
32
+ engine: "ClassificationEngine",
33
+ project_root: str | None = None,
34
+ ) -> "RiskAssessment":
35
+ """Compute governance-enriched risk score.
36
+
37
+ Wraps existing assess_risk() and adds governance bonuses.
38
+ """
39
+ from traceforge.classify.risk import assess_risk
40
+
41
+ if command_analysis:
42
+ base = assess_risk(
43
+ classification=enriched_classification,
44
+ command=command_analysis.command or "",
45
+ engine=engine,
46
+ binary=command_analysis.binary or "",
47
+ flags=list(command_analysis.flags) if command_analysis.flags else [],
48
+ targets=list(command_analysis.targets) if command_analysis.targets else [],
49
+ pipe_segments=[
50
+ {"binary": seg.binary, "targets": list(seg.targets)}
51
+ for seg in command_analysis.pipe_segments
52
+ ]
53
+ if command_analysis.pipe_segments
54
+ else None,
55
+ project_root=project_root,
56
+ )
57
+ else:
58
+ base = assess_risk(
59
+ classification=enriched_classification,
60
+ command="",
61
+ engine=engine,
62
+ binary="",
63
+ flags=[],
64
+ targets=[],
65
+ pipe_segments=None,
66
+ project_root=project_root,
67
+ )
68
+
69
+ # Add governance bonuses (additive, capped at 100)
70
+ bonus = (
71
+ risk_modifiers.phase_drift_bonus
72
+ + risk_modifiers.mcp_drift_bonus
73
+ + risk_modifiers.integrity_bonus
74
+ + risk_modifiers.budget_bonus
75
+ )
76
+ if risk_modifiers.ifc_violations > 0:
77
+ bonus += min(risk_modifiers.ifc_violations * 10, 30)
78
+
79
+ final_score = min(base.score + bonus, 100)
80
+
81
+ # Append governance factors
82
+ extra_factors: list[str] = []
83
+ if risk_modifiers.phase_drift_bonus > 0:
84
+ extra_factors.append(f"phase_drift:+{risk_modifiers.phase_drift_bonus}")
85
+ if risk_modifiers.mcp_drift_bonus > 0:
86
+ extra_factors.append(f"mcp_drift:+{risk_modifiers.mcp_drift_bonus}")
87
+ if risk_modifiers.ifc_violations > 0:
88
+ extra_factors.append(f"ifc_violations:{risk_modifiers.ifc_violations}")
89
+ if risk_modifiers.integrity_bonus > 0:
90
+ extra_factors.append("integrity_unverified")
91
+ if risk_modifiers.budget_bonus > 0:
92
+ extra_factors.append("budget_pressure")
93
+
94
+ # Compute level from final score
95
+ level = _score_to_level(final_score)
96
+
97
+ return dataclasses.replace(
98
+ base,
99
+ score=final_score,
100
+ level=level,
101
+ factors=base.factors + tuple(extra_factors),
102
+ )
103
+
104
+
105
+ def _score_to_level(score: int) -> str:
106
+ """Map 0-100 score to level string."""
107
+ if score >= 85:
108
+ return "critical"
109
+ if score >= 65:
110
+ return "danger"
111
+ if score >= 40:
112
+ return "caution"
113
+ return "safe"