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
cli/audit.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CLI audit adapter — thin shim over ``cml.audit.AuditEngine``.
|
|
3
|
+
|
|
4
|
+
This module previously contained a parallel re-implementation of rules
|
|
5
|
+
R1–R4 which kept drifting from the SDK engine (cf. the duplicate-finding
|
|
6
|
+
bug fixed in PR #57 and the SAST report at
|
|
7
|
+
docs/reviews/sast-report-2026-04-24.md).
|
|
8
|
+
|
|
9
|
+
Now it just converts ``list[dict]`` ↔ the CLI's expected output shape
|
|
10
|
+
and delegates rule evaluation to the SDK so there is exactly one source
|
|
11
|
+
of truth for audit semantics.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from cml.audit import AuditConfig, AuditEngine, Severity
|
|
16
|
+
from cml.record import CausalRecord
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _code_to_rule(code: str) -> str:
|
|
20
|
+
# "CML-AUDIT-R1-MISSING_PARENT" -> "R1"; falls back to "?" for unknown codes
|
|
21
|
+
parts = code.split("-")
|
|
22
|
+
return parts[2] if len(parts) >= 3 else "?"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def audit(records: list[dict], _config: dict | None = None) -> dict:
|
|
26
|
+
"""Run R1–R4 audit on raw record dicts.
|
|
27
|
+
|
|
28
|
+
Returns the dict shape ``cli/main.py`` expects:
|
|
29
|
+
{"summary": {total, ok, warn, fail, passed}, "findings": [...]}
|
|
30
|
+
Each finding has ``rule``, ``code``, ``severity``, ``record_id``,
|
|
31
|
+
``line``, and ``message``. Records that produce no findings are
|
|
32
|
+
represented by a single synthetic ``"rule": "OK"`` entry so the CLI
|
|
33
|
+
can render a per-record status.
|
|
34
|
+
"""
|
|
35
|
+
cml_records: list[CausalRecord] = []
|
|
36
|
+
id_to_line: dict[str, int] = {}
|
|
37
|
+
for i, raw in enumerate(records):
|
|
38
|
+
rid = raw.get("id")
|
|
39
|
+
if rid:
|
|
40
|
+
id_to_line[rid] = i + 1
|
|
41
|
+
cml_records.append(CausalRecord.from_dict(raw))
|
|
42
|
+
|
|
43
|
+
result = AuditEngine(AuditConfig()).run(cml_records)
|
|
44
|
+
|
|
45
|
+
findings_by_record: dict[str, list[dict]] = {}
|
|
46
|
+
for f in result.findings:
|
|
47
|
+
findings_by_record.setdefault(f.record_id, []).append({
|
|
48
|
+
"rule": _code_to_rule(f.code),
|
|
49
|
+
"code": f.code,
|
|
50
|
+
"severity": f.severity,
|
|
51
|
+
"record_id": f.record_id,
|
|
52
|
+
"line": id_to_line.get(f.record_id),
|
|
53
|
+
"message": f.message,
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
findings: list[dict] = []
|
|
57
|
+
for rec in cml_records:
|
|
58
|
+
per_rec = findings_by_record.get(rec.id)
|
|
59
|
+
if per_rec:
|
|
60
|
+
findings.extend(per_rec)
|
|
61
|
+
else:
|
|
62
|
+
findings.append({
|
|
63
|
+
"rule": "OK",
|
|
64
|
+
"code": "OK",
|
|
65
|
+
"severity": Severity.OK,
|
|
66
|
+
"record_id": rec.id,
|
|
67
|
+
"line": id_to_line.get(rec.id),
|
|
68
|
+
"message": "All rules passed",
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
fail = sum(1 for f in findings if f["severity"] == Severity.FAIL)
|
|
72
|
+
warn = sum(1 for f in findings if f["severity"] == Severity.WARN)
|
|
73
|
+
ok = sum(1 for f in findings if f["severity"] == Severity.OK)
|
|
74
|
+
return {
|
|
75
|
+
"summary": {
|
|
76
|
+
"total": len(findings),
|
|
77
|
+
"ok": ok,
|
|
78
|
+
"warn": warn,
|
|
79
|
+
"fail": fail,
|
|
80
|
+
"passed": fail == 0,
|
|
81
|
+
},
|
|
82
|
+
"findings": findings,
|
|
83
|
+
}
|
cli/chain.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Causal chain reconstruction.
|
|
3
|
+
Given a record ID and a log, follows parent_cause links to the root.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def reconstruct_chain(records: list[dict], target_id: str) -> dict:
|
|
11
|
+
"""
|
|
12
|
+
Reconstruct the causal chain ending at target_id.
|
|
13
|
+
|
|
14
|
+
Returns a dict with:
|
|
15
|
+
target_id: str
|
|
16
|
+
chain: list of records from root to target (in order)
|
|
17
|
+
has_gap: bool — True when the chain terminates with a gap (not a root_event)
|
|
18
|
+
gap_note: str | None
|
|
19
|
+
r3_context: dict | None — filled when a SECRET access is NOT in the chain
|
|
20
|
+
but shares the same PID as the target
|
|
21
|
+
"""
|
|
22
|
+
ROOT_PREFIX = "root_event:"
|
|
23
|
+
id_to_record: dict[str, Any] = {
|
|
24
|
+
rec.get("id"): rec for rec in records if rec.get("id")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if target_id not in id_to_record:
|
|
28
|
+
return {
|
|
29
|
+
"target_id": target_id,
|
|
30
|
+
"chain": [],
|
|
31
|
+
"has_gap": True,
|
|
32
|
+
"gap_note": f"Record '{target_id}' not found in log",
|
|
33
|
+
"r3_context": None,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Walk backwards from target to root
|
|
37
|
+
chain_reversed: list[dict] = []
|
|
38
|
+
visited: set[str] = set()
|
|
39
|
+
current_id: str | None = target_id
|
|
40
|
+
has_gap = False
|
|
41
|
+
gap_note: str | None = None
|
|
42
|
+
|
|
43
|
+
while current_id:
|
|
44
|
+
if current_id in visited:
|
|
45
|
+
gap_note = f"Cycle detected at '{current_id}' — chain truncated"
|
|
46
|
+
has_gap = True
|
|
47
|
+
break
|
|
48
|
+
visited.add(current_id)
|
|
49
|
+
rec = id_to_record.get(current_id)
|
|
50
|
+
if not rec:
|
|
51
|
+
gap_note = f"Record '{current_id}' referenced by parent_cause is missing from log"
|
|
52
|
+
has_gap = True
|
|
53
|
+
break
|
|
54
|
+
chain_reversed.append(rec)
|
|
55
|
+
parent_cause = rec.get("parent_cause")
|
|
56
|
+
if parent_cause is None:
|
|
57
|
+
permitted_by = rec.get("permitted_by", "")
|
|
58
|
+
if not (isinstance(permitted_by, str) and permitted_by.startswith(ROOT_PREFIX)):
|
|
59
|
+
has_gap = True
|
|
60
|
+
if permitted_by == "unobserved_parent":
|
|
61
|
+
gap_note = (
|
|
62
|
+
f"Chain has a gap at '{current_id}': "
|
|
63
|
+
"parent is unobserved (permitted_by: unobserved_parent)"
|
|
64
|
+
)
|
|
65
|
+
else:
|
|
66
|
+
gap_note = (
|
|
67
|
+
f"Chain terminates at '{current_id}' with parent_cause=null "
|
|
68
|
+
f"(permitted_by: '{permitted_by}') — neither a root nor a marked gap"
|
|
69
|
+
)
|
|
70
|
+
break
|
|
71
|
+
current_id = parent_cause
|
|
72
|
+
|
|
73
|
+
chain = list(reversed(chain_reversed))
|
|
74
|
+
chain_ids = {r.get("id") for r in chain}
|
|
75
|
+
|
|
76
|
+
# Check for R3 context: SECRET access by the same process not in chain
|
|
77
|
+
target_rec = id_to_record[target_id]
|
|
78
|
+
target_action = target_rec.get("action", "")
|
|
79
|
+
target_pid = target_rec.get("actor", {}).get("pid")
|
|
80
|
+
r3_context: dict | None = None
|
|
81
|
+
|
|
82
|
+
if target_action in ("connect", "send") and target_pid is not None:
|
|
83
|
+
for rec in records:
|
|
84
|
+
if rec.get("actor", {}).get("pid") != target_pid:
|
|
85
|
+
continue
|
|
86
|
+
rec_action = rec.get("action", "")
|
|
87
|
+
rec_obj = rec.get("object")
|
|
88
|
+
is_secret = (
|
|
89
|
+
isinstance(rec_obj, dict)
|
|
90
|
+
and (
|
|
91
|
+
rec_obj.get("classification") == "SECRET"
|
|
92
|
+
or str(rec_obj.get("path", "")).startswith("/secrets/")
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
if rec_action in ("open", "read") and is_secret:
|
|
96
|
+
if rec.get("id") not in chain_ids:
|
|
97
|
+
r3_context = {
|
|
98
|
+
"secret_record": rec,
|
|
99
|
+
"note": (
|
|
100
|
+
f"SECRET access ({rec.get('id')} {rec_action} "
|
|
101
|
+
f"{rec_obj.get('path', '?') if isinstance(rec_obj, dict) else rec_obj})"
|
|
102
|
+
" by same process is NOT causally linked to this NET_OUT record"
|
|
103
|
+
),
|
|
104
|
+
}
|
|
105
|
+
break
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
"target_id": target_id,
|
|
109
|
+
"chain": chain,
|
|
110
|
+
"has_gap": has_gap,
|
|
111
|
+
"gap_note": gap_note,
|
|
112
|
+
"r3_context": r3_context,
|
|
113
|
+
}
|
cli/main.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CML CLI entry point.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python -m cli.main audit <file.jsonl> [--format json|text]
|
|
6
|
+
python -m cli.main chain <file.jsonl> <record_id>
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import json
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _is_strict_int(value: object) -> bool:
|
|
17
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_raw_record(raw: dict, line_no: int) -> None:
|
|
21
|
+
required = ("id", "timestamp", "actor", "action", "object", "permitted_by")
|
|
22
|
+
missing = [key for key in required if key not in raw]
|
|
23
|
+
if missing:
|
|
24
|
+
raise ValueError(f"Line {line_no}: missing required keys: {missing}")
|
|
25
|
+
if not isinstance(raw["id"], str) or not raw["id"].strip():
|
|
26
|
+
raise ValueError(f"Line {line_no}: field 'id' must be a non-empty string")
|
|
27
|
+
if not _is_strict_int(raw["timestamp"]):
|
|
28
|
+
raise ValueError(f"Line {line_no}: field 'timestamp' must be a strict integer")
|
|
29
|
+
if not isinstance(raw["action"], str) or not raw["action"].strip():
|
|
30
|
+
raise ValueError(f"Line {line_no}: field 'action' must be a non-empty string")
|
|
31
|
+
if not isinstance(raw["permitted_by"], str) or not raw["permitted_by"].strip():
|
|
32
|
+
raise ValueError(f"Line {line_no}: field 'permitted_by' must be a non-empty string")
|
|
33
|
+
actor = raw["actor"]
|
|
34
|
+
if not isinstance(actor, dict):
|
|
35
|
+
raise ValueError(f"Line {line_no}: field 'actor' must be an object")
|
|
36
|
+
if not _is_strict_int(actor.get("pid")) or not _is_strict_int(actor.get("uid")):
|
|
37
|
+
raise ValueError(f"Line {line_no}: actor.pid and actor.uid must be strict integers")
|
|
38
|
+
parent = raw.get("parent_cause")
|
|
39
|
+
if parent is not None and (not isinstance(parent, str) or not parent.strip()):
|
|
40
|
+
raise ValueError(f"Line {line_no}: field 'parent_cause' must be a non-empty string or null")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_jsonl(file_path: str) -> list[dict]:
|
|
44
|
+
records = []
|
|
45
|
+
with open(file_path, encoding="utf-8") as fh:
|
|
46
|
+
for line_no, line in enumerate(fh, start=1):
|
|
47
|
+
line = line.strip()
|
|
48
|
+
if not line:
|
|
49
|
+
continue
|
|
50
|
+
try:
|
|
51
|
+
raw = json.loads(line)
|
|
52
|
+
except json.JSONDecodeError as exc:
|
|
53
|
+
raise ValueError(f"Line {line_no}: invalid JSON ({exc})") from exc
|
|
54
|
+
if not isinstance(raw, dict):
|
|
55
|
+
raise ValueError(f"Line {line_no}: each JSONL entry must be an object")
|
|
56
|
+
_validate_raw_record(raw, line_no)
|
|
57
|
+
records.append(raw)
|
|
58
|
+
return records
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _cmd_audit(args: argparse.Namespace) -> None:
|
|
62
|
+
from cli.audit import audit
|
|
63
|
+
|
|
64
|
+
file_path = args.file
|
|
65
|
+
if not Path(file_path).exists():
|
|
66
|
+
print(f"[ERROR] File not found: {file_path}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
records = _load_jsonl(file_path)
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
print(f"[ERROR] Failed to parse log: {exc}", file=sys.stderr)
|
|
73
|
+
sys.exit(1)
|
|
74
|
+
result = audit(records)
|
|
75
|
+
result["file"] = file_path
|
|
76
|
+
|
|
77
|
+
if args.format == "json":
|
|
78
|
+
print(json.dumps(result, indent=2))
|
|
79
|
+
else:
|
|
80
|
+
s = result["summary"]
|
|
81
|
+
status = "PASSED" if s["passed"] else "FAILED"
|
|
82
|
+
print(f"CML Audit: {status}")
|
|
83
|
+
print(f" File : {file_path}")
|
|
84
|
+
print(f" Total: {s['total']} OK: {s['ok']} WARN: {s['warn']} FAIL: {s['fail']}")
|
|
85
|
+
for f in result["findings"]:
|
|
86
|
+
if f["severity"] != "OK":
|
|
87
|
+
loc = f"line {f['line']}" if f.get("line") else "?"
|
|
88
|
+
print(f" [{f['severity']}] {f['code']} @ {f['record_id']} ({loc})")
|
|
89
|
+
print(f" {f['message']}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _cmd_chain(args: argparse.Namespace) -> None:
|
|
93
|
+
from cli.chain import reconstruct_chain
|
|
94
|
+
|
|
95
|
+
file_path = args.file
|
|
96
|
+
if not Path(file_path).exists():
|
|
97
|
+
print(f"[ERROR] File not found: {file_path}", file=sys.stderr)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
records = _load_jsonl(file_path)
|
|
102
|
+
except ValueError as exc:
|
|
103
|
+
print(f"[ERROR] Failed to parse log: {exc}", file=sys.stderr)
|
|
104
|
+
sys.exit(1)
|
|
105
|
+
result = reconstruct_chain(records, args.record_id)
|
|
106
|
+
print(json.dumps(result, indent=2))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> None:
|
|
110
|
+
parser = argparse.ArgumentParser(
|
|
111
|
+
prog="cml",
|
|
112
|
+
description="Causal Memory Layer CLI — audit and chain inspection",
|
|
113
|
+
)
|
|
114
|
+
sub = parser.add_subparsers(dest="command", metavar="COMMAND")
|
|
115
|
+
|
|
116
|
+
audit_p = sub.add_parser("audit", help="Audit a JSONL causal log against CML rules")
|
|
117
|
+
audit_p.add_argument("file", help="Path to .jsonl causal log")
|
|
118
|
+
audit_p.add_argument(
|
|
119
|
+
"--format",
|
|
120
|
+
choices=["json", "text"],
|
|
121
|
+
default="text",
|
|
122
|
+
help="Output format (default: text)",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
chain_p = sub.add_parser("chain", help="Reconstruct causal chain for a record")
|
|
126
|
+
chain_p.add_argument("file", help="Path to .jsonl causal log")
|
|
127
|
+
chain_p.add_argument("record_id", help="ID of the record to trace")
|
|
128
|
+
|
|
129
|
+
args = parser.parse_args()
|
|
130
|
+
if not args.command:
|
|
131
|
+
parser.print_help()
|
|
132
|
+
sys.exit(1)
|
|
133
|
+
|
|
134
|
+
if args.command == "audit":
|
|
135
|
+
_cmd_audit(args)
|
|
136
|
+
elif args.command == "chain":
|
|
137
|
+
_cmd_chain(args)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
if __name__ == "__main__":
|
|
141
|
+
main()
|
cml/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
cml — Causal Memory Layer Python SDK
|
|
3
|
+
|
|
4
|
+
Core package for recording, computing, and auditing causal chains.
|
|
5
|
+
|
|
6
|
+
Modules:
|
|
7
|
+
record — CausalRecord, Actor, load_jsonl
|
|
8
|
+
ctag — CTAG 16-bit computation (DOM, CLASS, GEN, LHINT, SEAL)
|
|
9
|
+
chain — Chain reconstruction and path queries
|
|
10
|
+
audit — Audit engine (R1–R4)
|
|
11
|
+
report — Report generation (Markdown, JSON, text)
|
|
12
|
+
|
|
13
|
+
Quick start:
|
|
14
|
+
|
|
15
|
+
from cml import load_jsonl, AuditEngine, AuditConfig
|
|
16
|
+
|
|
17
|
+
records = load_jsonl("causal.jsonl")
|
|
18
|
+
engine = AuditEngine()
|
|
19
|
+
result = engine.run(records)
|
|
20
|
+
print(result.passed(), result.findings)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from importlib.metadata import PackageNotFoundError, version as _pkg_version
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
__version__ = _pkg_version("causal-memory-layer")
|
|
27
|
+
except PackageNotFoundError: # not installed (editable source checkout)
|
|
28
|
+
__version__ = "0.0.0+unknown"
|
|
29
|
+
|
|
30
|
+
from .record import CausalRecord, Actor, Action, load_jsonl, records_to_index
|
|
31
|
+
from .ctag import (
|
|
32
|
+
DOM, CLASS, CTAGState,
|
|
33
|
+
compute_ctag, decode_ctag, compute_lhint,
|
|
34
|
+
)
|
|
35
|
+
from .chain import reconstruct_chain, has_path, find_root, group_by_pid
|
|
36
|
+
from .audit import AuditEngine, AuditConfig, AuditResult, Finding, Severity, CustomRule
|
|
37
|
+
from .report import to_markdown, to_json, to_text
|
|
38
|
+
|
|
39
|
+
__all__ = [
|
|
40
|
+
# record
|
|
41
|
+
"CausalRecord", "Actor", "Action", "load_jsonl", "records_to_index",
|
|
42
|
+
# ctag
|
|
43
|
+
"DOM", "CLASS", "CTAGState", "compute_ctag", "decode_ctag", "compute_lhint",
|
|
44
|
+
# chain
|
|
45
|
+
"reconstruct_chain", "has_path", "find_root", "group_by_pid",
|
|
46
|
+
# audit
|
|
47
|
+
"AuditEngine", "AuditConfig", "AuditResult", "Finding", "Severity", "CustomRule",
|
|
48
|
+
# report
|
|
49
|
+
"to_markdown", "to_json", "to_text",
|
|
50
|
+
]
|