causal-memory-layer 0.4.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.
- api/__init__.py +1 -0
- api/server.py +515 -0
- api/store.py +189 -0
- causal_memory_layer-0.4.0.dist-info/METADATA +303 -0
- causal_memory_layer-0.4.0.dist-info/RECORD +29 -0
- causal_memory_layer-0.4.0.dist-info/WHEEL +5 -0
- causal_memory_layer-0.4.0.dist-info/entry_points.txt +3 -0
- causal_memory_layer-0.4.0.dist-info/licenses/LICENSE +21 -0
- causal_memory_layer-0.4.0.dist-info/licenses/LICENSE_COMMERCIAL.md +74 -0
- causal_memory_layer-0.4.0.dist-info/top_level.txt +3 -0
- cli/__init__.py +1 -0
- cli/audit.py +83 -0
- cli/chain.py +113 -0
- cli/main.py +141 -0
- cml/__init__.py +50 -0
- cml/audit.py +440 -0
- cml/chain.py +115 -0
- cml/ctag.py +274 -0
- cml/experimental/__init__.py +4 -0
- cml/experimental/cause_band.py +177 -0
- cml/experimental/cause_band_payload.py +16 -0
- cml/experimental/cause_band_trajectory.py +47 -0
- cml/integrations/__init__.py +1 -0
- cml/integrations/mcp/__init__.py +1 -0
- cml/integrations/mcp/core.py +55 -0
- cml/integrations/mcp/server.py +57 -0
- cml/record.py +196 -0
- cml/report.py +132 -0
- cml/safety_eval.py +189 -0
cml/ctag.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cml.ctag — Causal Tag (CTAG) computation (v0.4)
|
|
3
|
+
|
|
4
|
+
16-bit semantic marker:
|
|
5
|
+
|
|
6
|
+
b15..b12 DOM (4 bits) — trust domain
|
|
7
|
+
b11..b8 CLASS (4 bits) — action class
|
|
8
|
+
b7..b4 GEN (4 bits) — generation/epoch (mod 16)
|
|
9
|
+
b3..b1 LHINT (3 bits) — lineage hint derived from parent_cause
|
|
10
|
+
b0 SEAL (1 bit) — chain continuation flag
|
|
11
|
+
|
|
12
|
+
Reference: vcml/CTAG.md
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import warnings
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
# DOM table
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
class DOM:
|
|
26
|
+
UNKNOWN = 0
|
|
27
|
+
KERNEL = 1
|
|
28
|
+
PLATFORM = 2
|
|
29
|
+
SERVICE = 3
|
|
30
|
+
USER = 4
|
|
31
|
+
ADMIN = 5
|
|
32
|
+
CI_CD = 6
|
|
33
|
+
TENANT_A = 7
|
|
34
|
+
TENANT_B = 8
|
|
35
|
+
TENANT_C = 9
|
|
36
|
+
SANDBOX = 10
|
|
37
|
+
UNTRUSTED = 11
|
|
38
|
+
THIRD_PARTY = 12
|
|
39
|
+
AGENT = 13
|
|
40
|
+
BREAK_GLASS = 14
|
|
41
|
+
RESERVED = 15
|
|
42
|
+
|
|
43
|
+
_BY_NAME = {
|
|
44
|
+
"UNKNOWN": 0, "KERNEL": 1, "PLATFORM": 2, "SERVICE": 3,
|
|
45
|
+
"USER": 4, "ADMIN": 5, "CI_CD": 6,
|
|
46
|
+
"TENANT_A": 7, "TENANT_B": 8, "TENANT_C": 9,
|
|
47
|
+
"SANDBOX": 10, "UNTRUSTED": 11, "THIRD_PARTY": 12,
|
|
48
|
+
"AGENT": 13, "BREAK_GLASS": 14, "RESERVED": 15,
|
|
49
|
+
}
|
|
50
|
+
_BY_VAL = {v: k for k, v in _BY_NAME.items()}
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def name(val: int) -> str:
|
|
54
|
+
return DOM._BY_VAL.get(val, f"DOM({val})")
|
|
55
|
+
|
|
56
|
+
@staticmethod
|
|
57
|
+
def from_name(name: str) -> int:
|
|
58
|
+
return DOM._BY_NAME[name.upper()]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------------------
|
|
62
|
+
# CLASS table
|
|
63
|
+
# ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
class CLASS:
|
|
66
|
+
NONE = 0
|
|
67
|
+
READ = 1
|
|
68
|
+
WRITE = 2
|
|
69
|
+
EXEC = 3
|
|
70
|
+
NET_OUT = 4
|
|
71
|
+
NET_IN = 5
|
|
72
|
+
IPC = 6
|
|
73
|
+
PRIV = 7
|
|
74
|
+
CONFIG = 8
|
|
75
|
+
SECRET = 9
|
|
76
|
+
CRYPTO = 10
|
|
77
|
+
FIN_TX = 11
|
|
78
|
+
DATA_EGRESS = 12
|
|
79
|
+
ML_ACTION = 13
|
|
80
|
+
OVERRIDE = 14
|
|
81
|
+
SYSTEM = 15
|
|
82
|
+
|
|
83
|
+
_BY_NAME = {
|
|
84
|
+
"NONE": 0, "READ": 1, "WRITE": 2, "EXEC": 3,
|
|
85
|
+
"NET_OUT": 4, "NET_IN": 5, "IPC": 6, "PRIV": 7,
|
|
86
|
+
"CONFIG": 8, "SECRET": 9, "CRYPTO": 10, "FIN_TX": 11,
|
|
87
|
+
"DATA_EGRESS": 12, "ML_ACTION": 13, "OVERRIDE": 14, "SYSTEM": 15,
|
|
88
|
+
}
|
|
89
|
+
_BY_VAL = {v: k for k, v in _BY_NAME.items()}
|
|
90
|
+
|
|
91
|
+
@staticmethod
|
|
92
|
+
def name(val: int) -> str:
|
|
93
|
+
return CLASS._BY_VAL.get(val, f"CLASS({val})")
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def from_name(name: str) -> int:
|
|
97
|
+
return CLASS._BY_NAME[name.upper()]
|
|
98
|
+
|
|
99
|
+
@staticmethod
|
|
100
|
+
def from_action(action: str) -> int:
|
|
101
|
+
mapping = {
|
|
102
|
+
"exec": CLASS.EXEC,
|
|
103
|
+
"open": CLASS.READ,
|
|
104
|
+
"read": CLASS.READ,
|
|
105
|
+
"write": CLASS.WRITE,
|
|
106
|
+
"connect": CLASS.NET_OUT,
|
|
107
|
+
"send": CLASS.NET_OUT,
|
|
108
|
+
}
|
|
109
|
+
return mapping.get(action.lower(), CLASS.NONE)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# FNV-1a 64-bit
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
_FNV_OFFSET = 0xcbf29ce484222325
|
|
117
|
+
_FNV_PRIME = 0x100000001b3
|
|
118
|
+
_U64_MASK = 0xFFFFFFFFFFFFFFFF
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _fnv1a_64(data: str) -> int:
|
|
122
|
+
h = _FNV_OFFSET
|
|
123
|
+
for byte in data.encode("utf-8"):
|
|
124
|
+
h ^= byte
|
|
125
|
+
h = (h * _FNV_PRIME) & _U64_MASK
|
|
126
|
+
return h
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# LHINT formula (canonical v0.4)
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
def compute_lhint(parent_cause_id: str, dom: int, cls: int, gen: int) -> int:
|
|
134
|
+
"""
|
|
135
|
+
LHINT = (FNV1a64(parent_cause_id) XOR (dom<<4) XOR (cls<<0) XOR (gen<<2)) & 0b111
|
|
136
|
+
"""
|
|
137
|
+
h = _fnv1a_64(parent_cause_id)
|
|
138
|
+
return int((h ^ (dom << 4) ^ (cls << 0) ^ (gen << 2)) & 0b111)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# GEN bump rules
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
_GEN_BUMP_ACTIONS = {"exec", "priv"}
|
|
146
|
+
_GEN_BUMP_CLASSES = {CLASS.EXEC, CLASS.PRIV}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def should_bump_gen(action: str, cls: int, prev_dom: int, new_dom: int,
|
|
150
|
+
entering_break_glass: bool = False,
|
|
151
|
+
warn_on_mismatch: bool = True) -> bool:
|
|
152
|
+
"""Determine if the GEN counter should bump.
|
|
153
|
+
|
|
154
|
+
Both action string and CLASS enum are checked intentionally: the CLASS
|
|
155
|
+
enum is the authoritative source, but the action string provides a
|
|
156
|
+
fallback when the caller hasn't mapped action→class yet. A semantic
|
|
157
|
+
contradiction (e.g. action="open" with CLASS.EXEC) indicates a
|
|
158
|
+
misconfigured caller; the bump is still applied to err on the side of
|
|
159
|
+
caution (new generation = more conservative audit trail).
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
warn_on_mismatch: Emit a ``UserWarning`` when the action string and
|
|
163
|
+
``cls`` disagree. Set to ``False`` when the caller intentionally
|
|
164
|
+
overrides the default class mapping (e.g. an "open" that requires
|
|
165
|
+
elevated privileges and is classified as ``CLASS.PRIV``).
|
|
166
|
+
"""
|
|
167
|
+
# Validate action/CLASS consistency
|
|
168
|
+
expected_cls = CLASS.from_action(action)
|
|
169
|
+
if warn_on_mismatch and expected_cls != CLASS.NONE and expected_cls != cls:
|
|
170
|
+
warnings.warn(
|
|
171
|
+
f"Action '{action}' maps to {CLASS.name(expected_cls)} but "
|
|
172
|
+
f"cls={CLASS.name(cls)}; possible misconfiguration "
|
|
173
|
+
f"(bump applied conservatively).",
|
|
174
|
+
stacklevel=2,
|
|
175
|
+
)
|
|
176
|
+
if entering_break_glass:
|
|
177
|
+
return True
|
|
178
|
+
if action.lower() in _GEN_BUMP_ACTIONS:
|
|
179
|
+
return True
|
|
180
|
+
if cls in _GEN_BUMP_CLASSES:
|
|
181
|
+
return True
|
|
182
|
+
if prev_dom != new_dom:
|
|
183
|
+
return True
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
# ---------------------------------------------------------------------------
|
|
188
|
+
# Main CTAG computation
|
|
189
|
+
# ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
def compute_ctag(
|
|
192
|
+
dom: int,
|
|
193
|
+
cls: int,
|
|
194
|
+
gen: int,
|
|
195
|
+
parent_cause_id: Optional[str],
|
|
196
|
+
seal: bool = False,
|
|
197
|
+
) -> int:
|
|
198
|
+
"""
|
|
199
|
+
Compute the 16-bit CTAG value.
|
|
200
|
+
|
|
201
|
+
If parent_cause_id is None (root event), LHINT is 0.
|
|
202
|
+
"""
|
|
203
|
+
if parent_cause_id is not None:
|
|
204
|
+
lhint = compute_lhint(parent_cause_id, dom, cls, gen)
|
|
205
|
+
else:
|
|
206
|
+
lhint = 0
|
|
207
|
+
|
|
208
|
+
seal_bit = 1 if seal else 0
|
|
209
|
+
|
|
210
|
+
ctag = (
|
|
211
|
+
((dom & 0xF) << 12) |
|
|
212
|
+
((cls & 0xF) << 8) |
|
|
213
|
+
((gen & 0xF) << 4) |
|
|
214
|
+
((lhint & 0x7) << 1) |
|
|
215
|
+
seal_bit
|
|
216
|
+
)
|
|
217
|
+
return ctag & 0xFFFF
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def decode_ctag(ctag: int) -> dict:
|
|
221
|
+
"""Decode a 16-bit CTAG value into its components."""
|
|
222
|
+
dom = (ctag >> 12) & 0xF
|
|
223
|
+
cls = (ctag >> 8) & 0xF
|
|
224
|
+
gen = (ctag >> 4) & 0xF
|
|
225
|
+
lhint = (ctag >> 1) & 0x7
|
|
226
|
+
seal = ctag & 0x1
|
|
227
|
+
return {
|
|
228
|
+
"raw": f"0x{ctag:04X}",
|
|
229
|
+
"dom": dom,
|
|
230
|
+
"dom_name": DOM.name(dom),
|
|
231
|
+
"class": cls,
|
|
232
|
+
"class_name": CLASS.name(cls),
|
|
233
|
+
"gen": gen,
|
|
234
|
+
"lhint": lhint,
|
|
235
|
+
"seal": bool(seal),
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# ---------------------------------------------------------------------------
|
|
240
|
+
# CTAG propagation helper
|
|
241
|
+
# ---------------------------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
class CTAGState:
|
|
244
|
+
"""
|
|
245
|
+
Tracks CTAG state across a causal chain.
|
|
246
|
+
Manages GEN bumping and DOM transitions.
|
|
247
|
+
"""
|
|
248
|
+
|
|
249
|
+
def __init__(self, dom: int = DOM.USER, gen: int = 0):
|
|
250
|
+
self.dom = dom
|
|
251
|
+
self.gen = gen
|
|
252
|
+
|
|
253
|
+
def next(
|
|
254
|
+
self,
|
|
255
|
+
action: str,
|
|
256
|
+
cls: int,
|
|
257
|
+
parent_cause_id: Optional[str],
|
|
258
|
+
new_dom: Optional[int] = None,
|
|
259
|
+
seal: bool = False,
|
|
260
|
+
) -> int:
|
|
261
|
+
target_dom = new_dom if new_dom is not None else self.dom
|
|
262
|
+
if should_bump_gen(action, cls, self.dom, target_dom):
|
|
263
|
+
self.gen = (self.gen + 1) % 16
|
|
264
|
+
self.dom = target_dom
|
|
265
|
+
|
|
266
|
+
seal_auto = cls in (CLASS.OVERRIDE,)
|
|
267
|
+
ctag = compute_ctag(
|
|
268
|
+
dom=self.dom,
|
|
269
|
+
cls=cls,
|
|
270
|
+
gen=self.gen,
|
|
271
|
+
parent_cause_id=parent_cause_id,
|
|
272
|
+
seal=seal or seal_auto,
|
|
273
|
+
)
|
|
274
|
+
return ctag
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .cause_band_trajectory import is_oscillating, recovered_to_safe, trajectory_direction
|
|
9
|
+
|
|
10
|
+
BAND_RANK = {
|
|
11
|
+
"safe_range": 0,
|
|
12
|
+
"warning_range": 1,
|
|
13
|
+
"danger_range": 2,
|
|
14
|
+
"critical_range": 3,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
RANGE_DRIFT = "CML-AUDIT-RANGE-DRIFT"
|
|
18
|
+
PERSISTENT_DEVIATION = "CML-AUDIT-RANGE-PERSISTENT_DEVIATION"
|
|
19
|
+
CRITICAL_EXIT = "CML-AUDIT-RANGE-CRITICAL_EXIT"
|
|
20
|
+
|
|
21
|
+
DEFAULT_FIXTURE = Path("benchmarks/experimental/07_range_drift_intent.json")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_fixture_path(path: Path) -> Path:
|
|
25
|
+
"""Resolve fixture path safely, preventing directory traversal and absolute paths.
|
|
26
|
+
|
|
27
|
+
Accepts:
|
|
28
|
+
- Absolute path: rejected (security)
|
|
29
|
+
- Path with .. traversal: rejected (security)
|
|
30
|
+
- Full repo-relative path (e.g., 'benchmarks/experimental/07_*.json'): used as-is if exists
|
|
31
|
+
- Filename only (e.g., '07_range_drift_intent.json'): resolved within benchmarks/experimental/
|
|
32
|
+
"""
|
|
33
|
+
base_dir = DEFAULT_FIXTURE.parent.resolve()
|
|
34
|
+
|
|
35
|
+
if path.is_absolute():
|
|
36
|
+
raise SystemExit(f"Fixture path not allowed: {path}")
|
|
37
|
+
if any(part == ".." for part in path.parts):
|
|
38
|
+
raise SystemExit(f"Fixture path not allowed: {path}")
|
|
39
|
+
|
|
40
|
+
# Try to use path as-is first (handles repo-relative full paths)
|
|
41
|
+
candidate = Path(path)
|
|
42
|
+
if candidate.exists():
|
|
43
|
+
resolved = candidate.resolve()
|
|
44
|
+
# Verify it's still within or under the safety zone
|
|
45
|
+
try:
|
|
46
|
+
resolved.relative_to(base_dir.parent) # Allow within repo
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise SystemExit(f"Fixture path not allowed: {path}") from exc
|
|
49
|
+
return resolved
|
|
50
|
+
|
|
51
|
+
# Fall back to treating it as a filename within base_dir
|
|
52
|
+
candidate = base_dir / path.name
|
|
53
|
+
if not candidate.exists():
|
|
54
|
+
raise SystemExit(f"Fixture not found: {candidate}")
|
|
55
|
+
|
|
56
|
+
resolved = candidate.resolve()
|
|
57
|
+
try:
|
|
58
|
+
resolved.relative_to(base_dir)
|
|
59
|
+
except ValueError as exc:
|
|
60
|
+
raise SystemExit(f"Fixture path not allowed: {path}") from exc
|
|
61
|
+
return resolved
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_duration_threshold(raw: Any, default: int = 3) -> int:
|
|
65
|
+
if isinstance(raw, int) and raw > 0:
|
|
66
|
+
return raw
|
|
67
|
+
if isinstance(raw, str):
|
|
68
|
+
match = re.match(r"\s*(\d+)", raw)
|
|
69
|
+
if match:
|
|
70
|
+
value = int(match.group(1))
|
|
71
|
+
if value > 0:
|
|
72
|
+
return value
|
|
73
|
+
return default
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def load_fixture(path: Path) -> dict[str, Any]:
|
|
77
|
+
safe_path = resolve_fixture_path(path)
|
|
78
|
+
try:
|
|
79
|
+
raw = json.loads(safe_path.read_text(encoding="utf-8"))
|
|
80
|
+
except FileNotFoundError as exc:
|
|
81
|
+
raise SystemExit(f"Fixture not found: {safe_path}") from exc
|
|
82
|
+
except json.JSONDecodeError as exc:
|
|
83
|
+
raise SystemExit(f"Invalid JSON in {safe_path}: {exc}") from exc
|
|
84
|
+
if not isinstance(raw, dict):
|
|
85
|
+
raise SystemExit(f"Fixture must be a JSON object: {safe_path}")
|
|
86
|
+
return raw
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def max_consecutive_outside_safe(ranks: list[int]) -> int:
|
|
90
|
+
current = 0
|
|
91
|
+
best = 0
|
|
92
|
+
for rank in ranks:
|
|
93
|
+
if rank > BAND_RANK["safe_range"]:
|
|
94
|
+
current += 1
|
|
95
|
+
best = max(best, current)
|
|
96
|
+
else:
|
|
97
|
+
current = 0
|
|
98
|
+
return best
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def evaluate_fixture(raw: dict[str, Any]) -> dict[str, Any]:
|
|
102
|
+
trajectory = raw.get("trajectory")
|
|
103
|
+
if not isinstance(trajectory, list) or not trajectory:
|
|
104
|
+
raise SystemExit("Fixture must contain a non-empty trajectory list")
|
|
105
|
+
|
|
106
|
+
bands: list[str] = []
|
|
107
|
+
ranks: list[int] = []
|
|
108
|
+
invalid_bands: list[str] = []
|
|
109
|
+
|
|
110
|
+
for step in trajectory:
|
|
111
|
+
if not isinstance(step, dict):
|
|
112
|
+
raise SystemExit("Each trajectory step must be an object")
|
|
113
|
+
band = step.get("band")
|
|
114
|
+
if not isinstance(band, str):
|
|
115
|
+
raise SystemExit("Each trajectory step must contain a string band")
|
|
116
|
+
bands.append(band)
|
|
117
|
+
if band not in BAND_RANK:
|
|
118
|
+
invalid_bands.append(band)
|
|
119
|
+
continue
|
|
120
|
+
ranks.append(BAND_RANK[band])
|
|
121
|
+
|
|
122
|
+
if invalid_bands:
|
|
123
|
+
raise SystemExit(f"Unknown Cause Band values: {sorted(set(invalid_bands))}")
|
|
124
|
+
|
|
125
|
+
policy = raw.get("cause_band_policy") if isinstance(raw.get("cause_band_policy"), dict) else {}
|
|
126
|
+
duration_threshold = parse_duration_threshold(policy.get("duration_threshold"), default=3)
|
|
127
|
+
|
|
128
|
+
findings: list[str] = []
|
|
129
|
+
if any(rank >= BAND_RANK["warning_range"] for rank in ranks):
|
|
130
|
+
findings.append(RANGE_DRIFT)
|
|
131
|
+
|
|
132
|
+
max_consecutive = max_consecutive_outside_safe(ranks)
|
|
133
|
+
if max_consecutive >= duration_threshold:
|
|
134
|
+
findings.append(PERSISTENT_DEVIATION)
|
|
135
|
+
|
|
136
|
+
if any(rank >= BAND_RANK["critical_range"] for rank in ranks):
|
|
137
|
+
findings.append(CRITICAL_EXIT)
|
|
138
|
+
|
|
139
|
+
expected_future = raw.get("expected_future_cause_band_behavior")
|
|
140
|
+
expected_codes: list[str] = []
|
|
141
|
+
if isinstance(expected_future, dict) and isinstance(expected_future.get("expected_codes"), list):
|
|
142
|
+
expected_codes = sorted(str(code) for code in expected_future["expected_codes"])
|
|
143
|
+
|
|
144
|
+
predicted_codes = sorted(findings)
|
|
145
|
+
return {
|
|
146
|
+
"case_id": raw.get("case_id"),
|
|
147
|
+
"status": raw.get("status", "experimental"),
|
|
148
|
+
"duration_threshold": duration_threshold,
|
|
149
|
+
"bands": bands,
|
|
150
|
+
"trajectory_direction": trajectory_direction(ranks),
|
|
151
|
+
"recovered_to_safe": recovered_to_safe(ranks),
|
|
152
|
+
"oscillating": is_oscillating(ranks),
|
|
153
|
+
"max_consecutive_outside_safe": max_consecutive,
|
|
154
|
+
"predicted_codes": predicted_codes,
|
|
155
|
+
"expected_future_codes": expected_codes,
|
|
156
|
+
"matches_expected_future": predicted_codes == expected_codes if expected_codes else None,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def render_text(result: dict[str, Any]) -> str:
|
|
161
|
+
expected = result["expected_future_codes"] or ["<none>"]
|
|
162
|
+
lines = [
|
|
163
|
+
"Experimental Cause Band evaluation",
|
|
164
|
+
f"case_id={result['case_id']}",
|
|
165
|
+
f"status={result['status']}",
|
|
166
|
+
f"duration_threshold={result['duration_threshold']}",
|
|
167
|
+
f"bands={' -> '.join(result['bands'])}",
|
|
168
|
+
f"trajectory_direction={result['trajectory_direction']}",
|
|
169
|
+
f"recovered_to_safe={result['recovered_to_safe']}",
|
|
170
|
+
f"oscillating={result['oscillating']}",
|
|
171
|
+
f"max_consecutive_outside_safe={result['max_consecutive_outside_safe']}",
|
|
172
|
+
f"predicted_codes={','.join(result['predicted_codes']) or '-'}",
|
|
173
|
+
f"expected_future_codes={','.join(expected)}",
|
|
174
|
+
]
|
|
175
|
+
if result["matches_expected_future"] is not None:
|
|
176
|
+
lines.append(f"matches_expected_future={result['matches_expected_future']}")
|
|
177
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def extract_fixture_payload(raw: dict[str, Any]) -> dict[str, Any]:
|
|
7
|
+
"""Return a Cause Band fixture from either a fixture file or example sidecar.
|
|
8
|
+
|
|
9
|
+
Some demo files wrap the experimental Cause Band fixture under
|
|
10
|
+
`cause_band_sidecar` so the same JSON can describe a broader agent trace
|
|
11
|
+
and still carry a runnable Cause Band payload.
|
|
12
|
+
"""
|
|
13
|
+
sidecar = raw.get("cause_band_sidecar")
|
|
14
|
+
if isinstance(sidecar, dict):
|
|
15
|
+
return sidecar
|
|
16
|
+
return raw
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
SAFE_RANK = 0
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def recovered_to_safe(ranks: list[int]) -> bool:
|
|
7
|
+
seen_non_safe = False
|
|
8
|
+
for rank in ranks:
|
|
9
|
+
if rank > SAFE_RANK:
|
|
10
|
+
seen_non_safe = True
|
|
11
|
+
elif seen_non_safe and rank == SAFE_RANK:
|
|
12
|
+
return True
|
|
13
|
+
return False
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_oscillating(ranks: list[int]) -> bool:
|
|
17
|
+
seen_non_safe = False
|
|
18
|
+
seen_recovery = False
|
|
19
|
+
for rank in ranks:
|
|
20
|
+
if rank > SAFE_RANK:
|
|
21
|
+
if seen_recovery:
|
|
22
|
+
return True
|
|
23
|
+
seen_non_safe = True
|
|
24
|
+
elif seen_non_safe and rank == SAFE_RANK:
|
|
25
|
+
seen_recovery = True
|
|
26
|
+
return False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def trajectory_direction(ranks: list[int]) -> str:
|
|
30
|
+
if len(ranks) < 2:
|
|
31
|
+
return "stable"
|
|
32
|
+
if is_oscillating(ranks):
|
|
33
|
+
return "oscillating"
|
|
34
|
+
|
|
35
|
+
deltas = [right - left for left, right in zip(ranks, ranks[1:])]
|
|
36
|
+
has_up = any(delta > 0 for delta in deltas)
|
|
37
|
+
has_down = any(delta < 0 for delta in deltas)
|
|
38
|
+
|
|
39
|
+
if has_up and not has_down:
|
|
40
|
+
return "degrading"
|
|
41
|
+
if has_down and not has_up:
|
|
42
|
+
return "recovering"
|
|
43
|
+
if has_up and has_down:
|
|
44
|
+
if ranks[-1] == SAFE_RANK:
|
|
45
|
+
return "recovering"
|
|
46
|
+
return "mixed"
|
|
47
|
+
return "stable"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Optional CML integrations."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP integration for CML agent-audit tools."""
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from cml import AuditEngine
|
|
6
|
+
from cml.experimental.cause_band import evaluate_fixture
|
|
7
|
+
from cml.experimental.cause_band_payload import extract_fixture_payload
|
|
8
|
+
from cml.record import CausalRecord
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def health() -> dict[str, Any]:
|
|
12
|
+
return {
|
|
13
|
+
"service": "cml-agent-audit-mcp",
|
|
14
|
+
"status": "ok",
|
|
15
|
+
"tools": [
|
|
16
|
+
"health",
|
|
17
|
+
"audit_trace",
|
|
18
|
+
"evaluate_cause_band",
|
|
19
|
+
],
|
|
20
|
+
"scope": "experimental MCP integration skeleton",
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _records_from_payload(payload: Any) -> list[CausalRecord]:
|
|
25
|
+
if isinstance(payload, dict):
|
|
26
|
+
if isinstance(payload.get("records"), list):
|
|
27
|
+
raw_records = payload["records"]
|
|
28
|
+
elif isinstance(payload.get("agent_trace"), list):
|
|
29
|
+
raw_records = payload["agent_trace"]
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError("Trace payload must contain a 'records' or 'agent_trace' list")
|
|
32
|
+
elif isinstance(payload, list):
|
|
33
|
+
raw_records = payload
|
|
34
|
+
else:
|
|
35
|
+
raise ValueError("Trace payload must be a list or object")
|
|
36
|
+
|
|
37
|
+
records: list[CausalRecord] = []
|
|
38
|
+
for index, raw in enumerate(raw_records):
|
|
39
|
+
if not isinstance(raw, dict):
|
|
40
|
+
raise ValueError(f"Trace record at index {index} must be an object")
|
|
41
|
+
records.append(CausalRecord.from_dict(raw))
|
|
42
|
+
return records
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def audit_trace(payload: Any) -> dict[str, Any]:
|
|
46
|
+
"""Run the core CML audit engine over JSON-compatible records."""
|
|
47
|
+
records = _records_from_payload(payload)
|
|
48
|
+
return AuditEngine().run(records).to_dict()
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def evaluate_cause_band(payload: dict[str, Any]) -> dict[str, Any]:
|
|
52
|
+
"""Evaluate a Cause Band fixture or an example containing cause_band_sidecar."""
|
|
53
|
+
if not isinstance(payload, dict):
|
|
54
|
+
raise ValueError("Cause Band payload must be an object")
|
|
55
|
+
return evaluate_fixture(extract_fixture_payload(payload))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from . import core
|
|
7
|
+
|
|
8
|
+
_MISSING_MCP_EXTRA_MESSAGE = """\
|
|
9
|
+
The CML MCP server requires the optional MCP dependency.
|
|
10
|
+
|
|
11
|
+
Install CML with MCP support:
|
|
12
|
+
|
|
13
|
+
pip install "causal-memory-layer[mcp]"
|
|
14
|
+
|
|
15
|
+
For local development from the repository root:
|
|
16
|
+
|
|
17
|
+
pip install -e ".[mcp]"
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
from mcp.server.fastmcp import FastMCP
|
|
22
|
+
except ModuleNotFoundError as exc:
|
|
23
|
+
if exc.name != "mcp":
|
|
24
|
+
raise
|
|
25
|
+
FastMCP = None # type: ignore[assignment]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if FastMCP is not None:
|
|
29
|
+
mcp = FastMCP("cml-agent-audit")
|
|
30
|
+
|
|
31
|
+
@mcp.tool()
|
|
32
|
+
def health() -> dict[str, Any]:
|
|
33
|
+
"""Return basic CML MCP integration status."""
|
|
34
|
+
return core.health()
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
def audit_trace(payload: Any) -> dict[str, Any]:
|
|
38
|
+
"""Audit JSON-compatible CML records or an agent_trace payload."""
|
|
39
|
+
return core.audit_trace(payload)
|
|
40
|
+
|
|
41
|
+
@mcp.tool()
|
|
42
|
+
def evaluate_cause_band(payload: dict[str, Any]) -> dict[str, Any]:
|
|
43
|
+
"""Evaluate a Cause Band fixture or payload containing cause_band_sidecar."""
|
|
44
|
+
return core.evaluate_cause_band(payload)
|
|
45
|
+
else:
|
|
46
|
+
mcp = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> None:
|
|
50
|
+
if mcp is None:
|
|
51
|
+
print(_MISSING_MCP_EXTRA_MESSAGE, file=sys.stderr)
|
|
52
|
+
raise SystemExit(2)
|
|
53
|
+
mcp.run()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
main()
|