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