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/audit.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cml.audit — Audit engine (v0.6)
|
|
3
|
+
|
|
4
|
+
Implements read-only causal coherence analysis for vCML logs.
|
|
5
|
+
Rules: R1, R2, R3, R4 + custom rules (see vcml/audit.md)
|
|
6
|
+
|
|
7
|
+
Audit does NOT block, enforce, or replace security products.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Optional
|
|
16
|
+
from .record import CausalRecord, records_to_index
|
|
17
|
+
from .chain import group_by_pid, ancestors
|
|
18
|
+
from .ctag import CLASS
|
|
19
|
+
from .experimental.cause_band import (
|
|
20
|
+
DEFAULT_FIXTURE,
|
|
21
|
+
evaluate_fixture,
|
|
22
|
+
load_fixture,
|
|
23
|
+
resolve_fixture_path,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Severity
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
class Severity:
|
|
32
|
+
OK = "OK"
|
|
33
|
+
WARN = "WARN"
|
|
34
|
+
FAIL = "FAIL"
|
|
35
|
+
|
|
36
|
+
_ALLOWED = {OK, WARN, FAIL}
|
|
37
|
+
|
|
38
|
+
@staticmethod
|
|
39
|
+
def normalize(value: str) -> str:
|
|
40
|
+
normalized = str(value).upper()
|
|
41
|
+
if normalized not in Severity._ALLOWED:
|
|
42
|
+
raise ValueError(
|
|
43
|
+
f"Unknown severity: {value!r}. Allowed values: {sorted(Severity._ALLOWED)}"
|
|
44
|
+
)
|
|
45
|
+
return normalized
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Finding
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Finding:
|
|
54
|
+
code: str
|
|
55
|
+
severity: str
|
|
56
|
+
record_id: str
|
|
57
|
+
message: str
|
|
58
|
+
chain_ids: list[str] = field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
d = {
|
|
62
|
+
"code": self.code,
|
|
63
|
+
"severity": self.severity,
|
|
64
|
+
"record_id": self.record_id,
|
|
65
|
+
"message": self.message,
|
|
66
|
+
}
|
|
67
|
+
if self.chain_ids:
|
|
68
|
+
d["chain_ids"] = self.chain_ids
|
|
69
|
+
return d
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
# Custom rules
|
|
74
|
+
# ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CustomRule:
|
|
78
|
+
"""User-defined audit rule parsed from YAML ``custom_rules`` section."""
|
|
79
|
+
id: str
|
|
80
|
+
description: str
|
|
81
|
+
trigger_class: int # CLASS enum value
|
|
82
|
+
severity: str = Severity.FAIL
|
|
83
|
+
code: str = ""
|
|
84
|
+
require_ancestor_class: Optional[int] = None
|
|
85
|
+
require_ancestor_permitted_by_prefix: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
def to_dict(self) -> dict:
|
|
88
|
+
d: dict = {
|
|
89
|
+
"id": self.id,
|
|
90
|
+
"description": self.description,
|
|
91
|
+
"trigger_class": CLASS.name(self.trigger_class),
|
|
92
|
+
"severity": self.severity,
|
|
93
|
+
"code": self.code,
|
|
94
|
+
}
|
|
95
|
+
if self.require_ancestor_class is not None:
|
|
96
|
+
d["require_ancestor_class"] = CLASS.name(self.require_ancestor_class)
|
|
97
|
+
if self.require_ancestor_permitted_by_prefix is not None:
|
|
98
|
+
d["require_ancestor_permitted_by_prefix"] = self.require_ancestor_permitted_by_prefix
|
|
99
|
+
return d
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _effective_class(record: CausalRecord) -> int:
|
|
103
|
+
"""Determine a record's CLASS from its CTAG or action string."""
|
|
104
|
+
if record.ctag is not None:
|
|
105
|
+
return (record.ctag >> 8) & 0xF
|
|
106
|
+
return CLASS.from_action(record.action)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Audit configuration
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class AuditConfig:
|
|
115
|
+
root_event_prefix: str = "root_event:"
|
|
116
|
+
secret_classifications: list[str] = field(default_factory=lambda: ["SECRET"])
|
|
117
|
+
secret_path_prefixes: list[str] = field(default_factory=lambda: ["/secrets/"])
|
|
118
|
+
secret_extensions: list[str] = field(default_factory=lambda: [".key", ".pem"])
|
|
119
|
+
net_out_actions: list[str] = field(default_factory=lambda: ["connect", "send"])
|
|
120
|
+
rules_enabled: dict[str, bool] = field(default_factory=lambda: {
|
|
121
|
+
"R1": True, "R2": True, "R3": True, "R4": True
|
|
122
|
+
})
|
|
123
|
+
custom_rules: list[CustomRule] = field(default_factory=list)
|
|
124
|
+
enable_experimental_cause_band: bool = False
|
|
125
|
+
experimental_cause_band_fixture: Optional[str] = None
|
|
126
|
+
|
|
127
|
+
@staticmethod
|
|
128
|
+
def _apply_raw(cfg: "AuditConfig", raw: dict) -> "AuditConfig":
|
|
129
|
+
if not isinstance(raw, dict):
|
|
130
|
+
raise ValueError("Audit config root must be a mapping/object")
|
|
131
|
+
|
|
132
|
+
cfg.root_event_prefix = raw.get("root_event_prefix", cfg.root_event_prefix)
|
|
133
|
+
s = raw.get("secret", {})
|
|
134
|
+
if "classifications" in s:
|
|
135
|
+
cfg.secret_classifications = s["classifications"]
|
|
136
|
+
if "path_prefixes" in s:
|
|
137
|
+
cfg.secret_path_prefixes = s["path_prefixes"]
|
|
138
|
+
if "extensions" in s:
|
|
139
|
+
cfg.secret_extensions = s["extensions"]
|
|
140
|
+
n = raw.get("net_out", {})
|
|
141
|
+
if "actions" in n:
|
|
142
|
+
cfg.net_out_actions = n["actions"]
|
|
143
|
+
experimental = raw.get("experimental", {})
|
|
144
|
+
if isinstance(experimental, dict):
|
|
145
|
+
cfg.enable_experimental_cause_band = bool(
|
|
146
|
+
experimental.get(
|
|
147
|
+
"enable_cause_band",
|
|
148
|
+
cfg.enable_experimental_cause_band,
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
if "cause_band_fixture" in experimental:
|
|
152
|
+
validated_fixture = resolve_fixture_path(Path(str(experimental["cause_band_fixture"])))
|
|
153
|
+
base_dir = DEFAULT_FIXTURE.parent.resolve()
|
|
154
|
+
cfg.experimental_cause_band_fixture = str(validated_fixture.relative_to(base_dir))
|
|
155
|
+
for rule in raw.get("rules", []):
|
|
156
|
+
rid = rule.get("id")
|
|
157
|
+
enabled = rule.get("enabled", True)
|
|
158
|
+
if rid:
|
|
159
|
+
cfg.rules_enabled[rid] = enabled
|
|
160
|
+
for cr in raw.get("custom_rules", []):
|
|
161
|
+
try:
|
|
162
|
+
trigger = CLASS.from_name(cr["trigger_class"])
|
|
163
|
+
except KeyError:
|
|
164
|
+
raise ValueError(f"Unknown trigger_class: {cr['trigger_class']}")
|
|
165
|
+
anc_cls = None
|
|
166
|
+
if "require_ancestor_class" in cr:
|
|
167
|
+
try:
|
|
168
|
+
anc_cls = CLASS.from_name(cr["require_ancestor_class"])
|
|
169
|
+
except KeyError:
|
|
170
|
+
raise ValueError(f"Unknown require_ancestor_class: {cr['require_ancestor_class']}")
|
|
171
|
+
cfg.custom_rules.append(CustomRule(
|
|
172
|
+
id=cr["id"],
|
|
173
|
+
description=cr.get("description", ""),
|
|
174
|
+
trigger_class=trigger,
|
|
175
|
+
severity=Severity.normalize(cr.get("severity", Severity.FAIL)),
|
|
176
|
+
code=cr.get("code", f"CML-AUDIT-{cr['id']}"),
|
|
177
|
+
require_ancestor_class=anc_cls,
|
|
178
|
+
require_ancestor_permitted_by_prefix=cr.get("require_ancestor_permitted_by_prefix"),
|
|
179
|
+
))
|
|
180
|
+
# Auto-enable the custom rule unless explicitly disabled
|
|
181
|
+
cfg.rules_enabled.setdefault(cr["id"], True)
|
|
182
|
+
return cfg
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def from_yaml(path: str) -> "AuditConfig":
|
|
186
|
+
with open(path) as f:
|
|
187
|
+
raw = yaml.safe_load(f) or {}
|
|
188
|
+
return AuditConfig._apply_raw(AuditConfig(), raw)
|
|
189
|
+
|
|
190
|
+
@staticmethod
|
|
191
|
+
def from_yaml_string(text: str) -> "AuditConfig":
|
|
192
|
+
raw = yaml.safe_load(text) or {}
|
|
193
|
+
return AuditConfig._apply_raw(AuditConfig(), raw)
|
|
194
|
+
|
|
195
|
+
def is_secret(self, record: CausalRecord) -> bool:
|
|
196
|
+
obj = record.object
|
|
197
|
+
if isinstance(obj, dict):
|
|
198
|
+
if obj.get("classification") in self.secret_classifications:
|
|
199
|
+
return True
|
|
200
|
+
path = obj.get("path", "")
|
|
201
|
+
else:
|
|
202
|
+
path = str(obj)
|
|
203
|
+
if any(path.startswith(p) for p in self.secret_path_prefixes):
|
|
204
|
+
return True
|
|
205
|
+
if any(path.endswith(e) for e in self.secret_extensions):
|
|
206
|
+
return True
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
def is_net_out(self, record: CausalRecord) -> bool:
|
|
210
|
+
return record.action in self.net_out_actions
|
|
211
|
+
|
|
212
|
+
def is_root(self, record: CausalRecord) -> bool:
|
|
213
|
+
return (
|
|
214
|
+
record.parent_cause is None
|
|
215
|
+
and isinstance(record.permitted_by, str)
|
|
216
|
+
and record.permitted_by.startswith(self.root_event_prefix)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
# Audit result
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class AuditResult:
|
|
226
|
+
total: int = 0
|
|
227
|
+
ok: int = 0
|
|
228
|
+
warnings: int = 0
|
|
229
|
+
failures: int = 0
|
|
230
|
+
findings: list[Finding] = field(default_factory=list)
|
|
231
|
+
|
|
232
|
+
def add(self, finding: Finding):
|
|
233
|
+
finding.severity = Severity.normalize(finding.severity)
|
|
234
|
+
self.findings.append(finding)
|
|
235
|
+
if finding.severity == Severity.OK:
|
|
236
|
+
self.ok += 1
|
|
237
|
+
elif finding.severity == Severity.WARN:
|
|
238
|
+
self.warnings += 1
|
|
239
|
+
elif finding.severity == Severity.FAIL:
|
|
240
|
+
self.failures += 1
|
|
241
|
+
|
|
242
|
+
def passed(self) -> bool:
|
|
243
|
+
return self.failures == 0
|
|
244
|
+
|
|
245
|
+
def to_dict(self) -> dict:
|
|
246
|
+
return {
|
|
247
|
+
"summary": {
|
|
248
|
+
"total": self.total,
|
|
249
|
+
"ok": self.ok,
|
|
250
|
+
"warnings": self.warnings,
|
|
251
|
+
"failures": self.failures,
|
|
252
|
+
"passed": self.passed(),
|
|
253
|
+
},
|
|
254
|
+
"findings": [f.to_dict() for f in self.findings],
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
# Audit engine
|
|
260
|
+
# ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
class AuditEngine:
|
|
263
|
+
def __init__(self, config: Optional[AuditConfig] = None):
|
|
264
|
+
self.config = config or AuditConfig()
|
|
265
|
+
|
|
266
|
+
def run(self, records: list[CausalRecord]) -> AuditResult:
|
|
267
|
+
cfg = self.config
|
|
268
|
+
index = records_to_index(records)
|
|
269
|
+
result = AuditResult(total=len(records))
|
|
270
|
+
|
|
271
|
+
r1_enabled = cfg.rules_enabled.get("R1", True)
|
|
272
|
+
r2_enabled = cfg.rules_enabled.get("R2", True)
|
|
273
|
+
r3_enabled = cfg.rules_enabled.get("R3", True)
|
|
274
|
+
r4_enabled = cfg.rules_enabled.get("R4", True)
|
|
275
|
+
|
|
276
|
+
for record in records:
|
|
277
|
+
# ----------------------------------------------------------
|
|
278
|
+
# R1 — Reference Integrity
|
|
279
|
+
# ----------------------------------------------------------
|
|
280
|
+
if r1_enabled and record.parent_cause is not None:
|
|
281
|
+
if record.parent_cause not in index:
|
|
282
|
+
result.add(Finding(
|
|
283
|
+
code="CML-AUDIT-R1-MISSING_PARENT",
|
|
284
|
+
severity=Severity.FAIL,
|
|
285
|
+
record_id=record.id,
|
|
286
|
+
message=(
|
|
287
|
+
f"parent_cause '{record.parent_cause}' "
|
|
288
|
+
f"does not exist in the log."
|
|
289
|
+
),
|
|
290
|
+
))
|
|
291
|
+
|
|
292
|
+
# ----------------------------------------------------------
|
|
293
|
+
# R2 / R4 — Gap Marking & Root Identification (mutually exclusive)
|
|
294
|
+
#
|
|
295
|
+
# For null-parent non-root records:
|
|
296
|
+
# R4 fires when permitted_by looks like a *near-miss* root label
|
|
297
|
+
# (starts with the root prefix stem but lacks the separator).
|
|
298
|
+
# Example: "root_event" instead of "root_event:system_boot".
|
|
299
|
+
# R2 fires for all other unlabeled cases (arbitrary permitted_by
|
|
300
|
+
# that is not "unobserved_parent" and not a near-miss root).
|
|
301
|
+
# ----------------------------------------------------------
|
|
302
|
+
if record.parent_cause is None and not cfg.is_root(record):
|
|
303
|
+
# Strip the last character (separator) to get the stem.
|
|
304
|
+
# Using slice instead of rstrip() which strips *characters*
|
|
305
|
+
# and would mangle multi-char separators like "::".
|
|
306
|
+
prefix_stem = cfg.root_event_prefix[:-1] if cfg.root_event_prefix else ""
|
|
307
|
+
near_miss = (
|
|
308
|
+
bool(prefix_stem)
|
|
309
|
+
and isinstance(record.permitted_by, str)
|
|
310
|
+
and record.permitted_by != "unobserved_parent"
|
|
311
|
+
and record.permitted_by.startswith(prefix_stem)
|
|
312
|
+
and not record.permitted_by.startswith(cfg.root_event_prefix)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
if r4_enabled and near_miss:
|
|
316
|
+
result.add(Finding(
|
|
317
|
+
code="CML-AUDIT-R4-AMBIGUOUS_ROOT",
|
|
318
|
+
severity=Severity.WARN,
|
|
319
|
+
record_id=record.id,
|
|
320
|
+
message=(
|
|
321
|
+
f"Near-miss root label: permitted_by='{record.permitted_by}' "
|
|
322
|
+
f"looks like '{cfg.root_event_prefix}' but is missing the "
|
|
323
|
+
f"required separator. Did you mean "
|
|
324
|
+
f"'{cfg.root_event_prefix}<cause>'?"
|
|
325
|
+
),
|
|
326
|
+
))
|
|
327
|
+
elif r2_enabled and not near_miss and record.permitted_by != "unobserved_parent":
|
|
328
|
+
result.add(Finding(
|
|
329
|
+
code="CML-AUDIT-R2-GAP_NOT_MARKED",
|
|
330
|
+
severity=Severity.WARN,
|
|
331
|
+
record_id=record.id,
|
|
332
|
+
message=(
|
|
333
|
+
f"Causal gap: parent_cause=null but permitted_by="
|
|
334
|
+
f"'{record.permitted_by}' (expected 'unobserved_parent')."
|
|
335
|
+
),
|
|
336
|
+
))
|
|
337
|
+
|
|
338
|
+
# ------------------------------------------------------------------
|
|
339
|
+
# R3 — SECRET → NET_OUT Chain
|
|
340
|
+
# ------------------------------------------------------------------
|
|
341
|
+
if r3_enabled:
|
|
342
|
+
by_pid = group_by_pid(records)
|
|
343
|
+
for pid, pid_records in by_pid.items():
|
|
344
|
+
secret_ids: list[str] = []
|
|
345
|
+
for r in pid_records:
|
|
346
|
+
if cfg.is_secret(r):
|
|
347
|
+
secret_ids.append(r.id)
|
|
348
|
+
|
|
349
|
+
if cfg.is_net_out(r) and secret_ids:
|
|
350
|
+
# Precompute ancestor set once per NET_OUT (O(chain_depth))
|
|
351
|
+
# instead of calling has_path per secret (O(S × depth)).
|
|
352
|
+
# Exclude r.id itself: ancestors() includes the record
|
|
353
|
+
# being tested, so a NET_OUT record that is also
|
|
354
|
+
# classified SECRET would falsely match itself.
|
|
355
|
+
anc = ancestors(r.id, index) - {r.id}
|
|
356
|
+
linked = bool(anc & set(secret_ids))
|
|
357
|
+
if not linked:
|
|
358
|
+
result.add(Finding(
|
|
359
|
+
code="CML-AUDIT-R3-SECRET_NET_MISSING_CHAIN",
|
|
360
|
+
severity=Severity.FAIL,
|
|
361
|
+
record_id=r.id,
|
|
362
|
+
message=(
|
|
363
|
+
f"NET_OUT '{r.action}' (pid={pid}) "
|
|
364
|
+
f"has no causal link to preceding "
|
|
365
|
+
f"SECRET access(es): {secret_ids}."
|
|
366
|
+
),
|
|
367
|
+
chain_ids=list(secret_ids),
|
|
368
|
+
))
|
|
369
|
+
|
|
370
|
+
# ------------------------------------------------------------------
|
|
371
|
+
# Custom rules (R5+)
|
|
372
|
+
#
|
|
373
|
+
# Ancestor sets are computed once per record (keyed by id) and
|
|
374
|
+
# reused across all rules, reducing O(R × N × D) to O(N × D + R × N).
|
|
375
|
+
# The cache is built lazily — only triggered records pay the cost.
|
|
376
|
+
# ------------------------------------------------------------------
|
|
377
|
+
if cfg.custom_rules:
|
|
378
|
+
_anc_cache: dict[str, set[str]] = {}
|
|
379
|
+
|
|
380
|
+
def _anc(rid: str) -> set[str]:
|
|
381
|
+
if rid not in _anc_cache:
|
|
382
|
+
_anc_cache[rid] = ancestors(rid, index) - {rid}
|
|
383
|
+
return _anc_cache[rid]
|
|
384
|
+
|
|
385
|
+
for rule in cfg.custom_rules:
|
|
386
|
+
rule_severity = Severity.normalize(rule.severity)
|
|
387
|
+
if not cfg.rules_enabled.get(rule.id, True):
|
|
388
|
+
continue
|
|
389
|
+
for record in records:
|
|
390
|
+
if _effective_class(record) != rule.trigger_class:
|
|
391
|
+
continue
|
|
392
|
+
satisfied = False
|
|
393
|
+
for aid in _anc(record.id):
|
|
394
|
+
anc = index.get(aid)
|
|
395
|
+
if anc is None:
|
|
396
|
+
continue
|
|
397
|
+
cls_ok = (
|
|
398
|
+
rule.require_ancestor_class is None
|
|
399
|
+
or _effective_class(anc) == rule.require_ancestor_class
|
|
400
|
+
)
|
|
401
|
+
prefix_ok = (
|
|
402
|
+
rule.require_ancestor_permitted_by_prefix is None
|
|
403
|
+
or (
|
|
404
|
+
isinstance(anc.permitted_by, str)
|
|
405
|
+
and anc.permitted_by.startswith(
|
|
406
|
+
rule.require_ancestor_permitted_by_prefix
|
|
407
|
+
)
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
if cls_ok and prefix_ok:
|
|
411
|
+
satisfied = True
|
|
412
|
+
break
|
|
413
|
+
if not satisfied:
|
|
414
|
+
result.add(Finding(
|
|
415
|
+
code=rule.code,
|
|
416
|
+
severity=rule_severity,
|
|
417
|
+
record_id=record.id,
|
|
418
|
+
message=f"Custom rule {rule.id}: {rule.description}",
|
|
419
|
+
))
|
|
420
|
+
|
|
421
|
+
# ------------------------------------------------------------------
|
|
422
|
+
# Experimental Cause Band sidecar evaluation
|
|
423
|
+
# ------------------------------------------------------------------
|
|
424
|
+
if cfg.enable_experimental_cause_band and cfg.experimental_cause_band_fixture:
|
|
425
|
+
cause_band_raw = load_fixture(Path(cfg.experimental_cause_band_fixture))
|
|
426
|
+
cause_band_result = evaluate_fixture(cause_band_raw)
|
|
427
|
+
for code in cause_band_result["predicted_codes"]:
|
|
428
|
+
result.add(Finding(
|
|
429
|
+
code=code,
|
|
430
|
+
severity=Severity.FAIL,
|
|
431
|
+
record_id=str(cause_band_result.get("case_id") or "experimental-cause-band"),
|
|
432
|
+
message=(
|
|
433
|
+
"Experimental Cause Band finding from sidecar fixture: "
|
|
434
|
+
f"{code}. This finding is non-normative and opt-in only."
|
|
435
|
+
),
|
|
436
|
+
chain_ids=[str(band) for band in cause_band_result.get("bands", [])],
|
|
437
|
+
))
|
|
438
|
+
|
|
439
|
+
result.ok = max(0, result.total - result.warnings - result.failures)
|
|
440
|
+
return result
|
cml/chain.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cml.chain — Causal chain reconstruction
|
|
3
|
+
|
|
4
|
+
Provides utilities to walk parent_cause links and reconstruct full
|
|
5
|
+
causal chains from an indexed log.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import Optional
|
|
11
|
+
from .record import CausalRecord
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
# Chain reconstruction
|
|
16
|
+
# ---------------------------------------------------------------------------
|
|
17
|
+
|
|
18
|
+
def reconstruct_chain(
|
|
19
|
+
record_id: str,
|
|
20
|
+
index: dict[str, CausalRecord],
|
|
21
|
+
max_depth: int = 256,
|
|
22
|
+
) -> list[CausalRecord]:
|
|
23
|
+
"""
|
|
24
|
+
Walk parent_cause links from `record_id` to the root.
|
|
25
|
+
|
|
26
|
+
Returns the chain ordered root-first (oldest → newest).
|
|
27
|
+
Stops at max_depth to prevent infinite loops on corrupt logs.
|
|
28
|
+
"""
|
|
29
|
+
chain: list[CausalRecord] = []
|
|
30
|
+
visited: set[str] = set()
|
|
31
|
+
current_id: Optional[str] = record_id
|
|
32
|
+
|
|
33
|
+
while current_id is not None:
|
|
34
|
+
if current_id in visited:
|
|
35
|
+
break # cycle guard
|
|
36
|
+
if len(chain) >= max_depth:
|
|
37
|
+
break
|
|
38
|
+
record = index.get(current_id)
|
|
39
|
+
if record is None:
|
|
40
|
+
break
|
|
41
|
+
chain.append(record)
|
|
42
|
+
visited.add(current_id)
|
|
43
|
+
current_id = record.parent_cause
|
|
44
|
+
|
|
45
|
+
chain.reverse()
|
|
46
|
+
return chain
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def find_root(
|
|
50
|
+
record_id: str,
|
|
51
|
+
index: dict[str, CausalRecord],
|
|
52
|
+
) -> Optional[CausalRecord]:
|
|
53
|
+
"""Return the root CausalRecord for the given record's chain."""
|
|
54
|
+
chain = reconstruct_chain(record_id, index)
|
|
55
|
+
return chain[0] if chain else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Chain path check
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def has_path(
|
|
63
|
+
from_id: str,
|
|
64
|
+
to_id: str,
|
|
65
|
+
index: dict[str, CausalRecord],
|
|
66
|
+
max_depth: int = 256,
|
|
67
|
+
) -> bool:
|
|
68
|
+
"""
|
|
69
|
+
Return True if `to_id` is an ancestor of `from_id` via parent_cause links.
|
|
70
|
+
|
|
71
|
+
Public utility for point-to-point reachability queries. The audit engine
|
|
72
|
+
uses ancestors() + set intersection for batch checks (O(chain_depth) vs
|
|
73
|
+
O(S × chain_depth)), but has_path is useful when testing a single pair.
|
|
74
|
+
"""
|
|
75
|
+
visited: set[str] = set()
|
|
76
|
+
start = index.get(from_id)
|
|
77
|
+
if start is None:
|
|
78
|
+
return False
|
|
79
|
+
current: Optional[str] = start.parent_cause
|
|
80
|
+
|
|
81
|
+
depth = 0
|
|
82
|
+
while current is not None and depth < max_depth:
|
|
83
|
+
if current == to_id:
|
|
84
|
+
return True
|
|
85
|
+
if current in visited:
|
|
86
|
+
return False
|
|
87
|
+
visited.add(current)
|
|
88
|
+
record = index.get(current)
|
|
89
|
+
if record is None:
|
|
90
|
+
return False
|
|
91
|
+
current = record.parent_cause
|
|
92
|
+
depth += 1
|
|
93
|
+
return False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
# Process context grouping
|
|
98
|
+
# ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
def group_by_pid(records: list[CausalRecord]) -> dict[int, list[CausalRecord]]:
|
|
101
|
+
"""Group records by actor.pid, sorted by timestamp."""
|
|
102
|
+
groups: dict[int, list[CausalRecord]] = {}
|
|
103
|
+
for r in sorted(records, key=lambda x: x.timestamp):
|
|
104
|
+
pid = r.actor.pid
|
|
105
|
+
groups.setdefault(pid, []).append(r)
|
|
106
|
+
return groups
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def ancestors(
|
|
110
|
+
record_id: str,
|
|
111
|
+
index: dict[str, CausalRecord],
|
|
112
|
+
) -> set[str]:
|
|
113
|
+
"""Return the set of all ancestor ids for a record."""
|
|
114
|
+
chain = reconstruct_chain(record_id, index)
|
|
115
|
+
return {r.id for r in chain}
|