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.
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
+ ]