cmdscope 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.
cmdscope/privacy.py ADDED
@@ -0,0 +1,143 @@
1
+ """Sanitization and environment-diff helpers.
2
+
3
+ CmdScope stores metadata, never file contents. Command arguments and environment
4
+ values are still potentially sensitive, so every value passes through this module
5
+ before it reaches SQLite, a report, or a log.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import re
12
+ import shlex
13
+ import subprocess
14
+ from collections.abc import Mapping
15
+
16
+ from cmdscope.core.events import JSONValue
17
+
18
+ REDACTED = "[REDACTED]"
19
+
20
+ # Normalized names are compared without punctuation, so AWS_SECRET_ACCESS_KEY,
21
+ # --api-key and clientSecret all match.
22
+ SENSITIVE_MARKERS: tuple[str, ...] = (
23
+ "password",
24
+ "passwd",
25
+ "secret",
26
+ "token",
27
+ "credential",
28
+ "apikey",
29
+ "accesskey",
30
+ "privatekey",
31
+ "clientsecret",
32
+ "authorization",
33
+ "cookie",
34
+ "session",
35
+ )
36
+
37
+ _SENSITIVE_FLAG_RE = re.compile(r"^[-/]{1,2}[A-Za-z0-9_.-]+$")
38
+ _URL_CREDENTIALS_RE = re.compile(r"(://[^/\s:@]+:)([^/\s@]+)(@)")
39
+ _AUTHORIZATION_RE = re.compile(r"(?i)(authorization\s*:\s*(?:bearer|basic|token)?\s*)\S+")
40
+ _BEARER_RE = re.compile(r"(?i)\b(bearer)\s+[A-Za-z0-9._~+/-]+=*")
41
+ _SENSITIVE_QUERY_RE = re.compile(
42
+ r"(?i)([?&](?:token|api_key|apikey|access_token|secret|password)=)[^&\s]+"
43
+ )
44
+
45
+
46
+ def is_sensitive_name(name: str) -> bool:
47
+ """Return whether a flag or environment-variable name looks sensitive."""
48
+
49
+ normalized = re.sub(r"[^a-z0-9]", "", name.lower())
50
+ return any(marker in normalized for marker in SENSITIVE_MARKERS)
51
+
52
+
53
+ def sanitize_env_value(name: str, value: str) -> str:
54
+ """Redact a value when its variable name is sensitive."""
55
+
56
+ return REDACTED if is_sensitive_name(name) else value
57
+
58
+
59
+ def sanitize_environment(environment: Mapping[str, str]) -> dict[str, str]:
60
+ """Return a copy with sensitive values redacted."""
61
+
62
+ return {key: sanitize_env_value(key, value) for key, value in environment.items()}
63
+
64
+
65
+ def environment_diff(
66
+ parent: Mapping[str, str],
67
+ child: Mapping[str, str],
68
+ ) -> dict[str, JSONValue]:
69
+ """Describe added, removed, and changed variables with sanitized values.
70
+
71
+ Runtime mutation of an environment block is not observable without native
72
+ tracing. This diff only compares the environment CmdScope was asked to use
73
+ for the child with the environment of the CmdScope process.
74
+ """
75
+
76
+ added: dict[str, JSONValue] = {
77
+ key: sanitize_env_value(key, child[key]) for key in sorted(child.keys() - parent.keys())
78
+ }
79
+ removed: list[JSONValue] = [key for key in sorted(parent.keys() - child.keys())]
80
+ changed: dict[str, JSONValue] = {}
81
+ for key in sorted(parent.keys() & child.keys()):
82
+ if parent[key] == child[key]:
83
+ continue
84
+ changed[key] = {
85
+ "before": sanitize_env_value(key, parent[key]),
86
+ "after": sanitize_env_value(key, child[key]),
87
+ }
88
+ return {"added": added, "removed": removed, "changed": changed}
89
+
90
+
91
+ def _sanitize_embedded(value: str) -> str:
92
+ """Redact credentials embedded inside an argument (URLs, headers, queries)."""
93
+
94
+ sanitized = _URL_CREDENTIALS_RE.sub(rf"\1{REDACTED}\3", value)
95
+ sanitized = _AUTHORIZATION_RE.sub(rf"\1{REDACTED}", sanitized)
96
+ sanitized = _BEARER_RE.sub(rf"\1 {REDACTED}", sanitized)
97
+ return _SENSITIVE_QUERY_RE.sub(rf"\1{REDACTED}", sanitized)
98
+
99
+
100
+ def sanitize_command(argv: tuple[str, ...] | list[str]) -> tuple[str, ...]:
101
+ """Redact values of sensitive flags, assignments, URLs, and headers."""
102
+
103
+ sanitized: list[str] = []
104
+ redact_next = False
105
+ for argument in argv:
106
+ if redact_next:
107
+ sanitized.append(REDACTED)
108
+ redact_next = False
109
+ continue
110
+
111
+ handled = False
112
+ if argument.startswith(("-", "/")):
113
+ for separator in ("=", ":"):
114
+ if separator not in argument:
115
+ continue
116
+ flag, _, _value = argument.partition(separator)
117
+ if is_sensitive_name(flag.lstrip("-/")):
118
+ sanitized.append(f"{flag}{separator}{REDACTED}")
119
+ handled = True
120
+ break
121
+ if handled:
122
+ continue
123
+ if _SENSITIVE_FLAG_RE.match(argument) and is_sensitive_name(argument.lstrip("-/")):
124
+ sanitized.append(argument)
125
+ redact_next = True
126
+ continue
127
+ elif "=" in argument:
128
+ name, _, _value = argument.partition("=")
129
+ if is_sensitive_name(name):
130
+ sanitized.append(f"{name}={REDACTED}")
131
+ continue
132
+
133
+ sanitized.append(_sanitize_embedded(argument))
134
+ return tuple(sanitized)
135
+
136
+
137
+ def format_command(argv: tuple[str, ...] | list[str]) -> str:
138
+ """Render a sanitized command for display and storage."""
139
+
140
+ sanitized = sanitize_command(argv)
141
+ if os.name == "nt":
142
+ return subprocess.list2cmdline(list(sanitized))
143
+ return shlex.join(sanitized)
cmdscope/py.typed ADDED
File without changes
@@ -0,0 +1 @@
1
+ """Report renderers."""
@@ -0,0 +1,279 @@
1
+ """Self-contained HTML reports with no external assets or scripts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import html
6
+ import json
7
+ from typing import Any
8
+
9
+ _CSS = """
10
+ :root { color-scheme: light dark; --bg:#f7f8fa; --panel:#fff; --text:#1c2430;
11
+ --muted:#5b6878; --line:#dfe4ea; --accent:#0b7285; --ok:#2b8a3e; --warn:#b36b00;
12
+ --fail:#c92a2a; --code:#f1f3f5; }
13
+ @media (prefers-color-scheme: dark) {
14
+ :root { --bg:#101418; --panel:#171d24; --text:#e7edf3; --muted:#9aa8b6;
15
+ --line:#2b3540; --accent:#4dabf7; --ok:#69db7c; --warn:#ffc078; --fail:#ff8787;
16
+ --code:#0d1116; }
17
+ }
18
+ * { box-sizing: border-box; }
19
+ body { margin:0; padding:2rem; background:var(--bg); color:var(--text);
20
+ font:15px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Arial,sans-serif; }
21
+ main { max-width:1100px; margin:0 auto; }
22
+ h1 { margin:0 0 .2rem; font-size:1.7rem; letter-spacing:-.02em; }
23
+ h2 { margin:2rem 0 .7rem; font-size:1.05rem; text-transform:uppercase;
24
+ letter-spacing:.08em; color:var(--muted); }
25
+ .sub { color:var(--muted); margin:0 0 1.5rem; }
26
+ .panel { background:var(--panel); border:1px solid var(--line); border-radius:10px;
27
+ padding:1rem 1.2rem; margin:.8rem 0; }
28
+ .cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr));
29
+ gap:.7rem; margin:1rem 0; }
30
+ .card { background:var(--panel); border:1px solid var(--line); border-radius:10px;
31
+ padding:.8rem 1rem; }
32
+ .card .num { font-size:1.5rem; font-weight:650; }
33
+ .card .label { color:var(--muted); font-size:.82rem; text-transform:uppercase;
34
+ letter-spacing:.06em; }
35
+ table { width:100%; border-collapse:collapse; font-size:.9rem; }
36
+ th,td { text-align:left; padding:.45rem .55rem; border-bottom:1px solid var(--line);
37
+ vertical-align:top; }
38
+ th { color:var(--muted); font-weight:600; font-size:.78rem; text-transform:uppercase;
39
+ letter-spacing:.05em; }
40
+ tr:last-child td { border-bottom:none; }
41
+ code,pre { font-family:ui-monospace,SFMono-Regular,Consolas,"Liberation Mono",monospace; }
42
+ code { background:var(--code); padding:.08rem .3rem; border-radius:4px; }
43
+ pre { background:var(--code); border:1px solid var(--line); border-radius:8px;
44
+ padding:.8rem; overflow:auto; font-size:.82rem; }
45
+ .badge { display:inline-block; padding:.08rem .45rem; border-radius:999px;
46
+ border:1px solid var(--line); font-size:.75rem; font-weight:600; }
47
+ .ok { color:var(--ok); border-color:var(--ok); }
48
+ .warn { color:var(--warn); border-color:var(--warn); }
49
+ .fail { color:var(--fail); border-color:var(--fail); }
50
+ .muted { color:var(--muted); }
51
+ .empty { color:var(--muted); font-style:italic; }
52
+ ul { margin:.3rem 0 .3rem 1.2rem; padding:0; }
53
+ li { margin:.18rem 0; }
54
+ footer { margin-top:2rem; padding-top:1rem; border-top:1px solid var(--line);
55
+ color:var(--muted); font-size:.82rem; }
56
+ """
57
+
58
+
59
+ def _e(value: object) -> str:
60
+ return html.escape("" if value is None else str(value), quote=True)
61
+
62
+
63
+ def _iso(value: object) -> str:
64
+ text = str(value) if value is not None else "-"
65
+ return text.replace("T", " ").replace("+00:00", " UTC")
66
+
67
+
68
+ def _duration(value: object) -> str:
69
+ if not isinstance(value, (int, float)):
70
+ return "-"
71
+ return f"{value * 1000:.0f} ms" if value < 1 else f"{value:.2f} s"
72
+
73
+
74
+ def _size(value: object) -> str:
75
+ if not isinstance(value, int):
76
+ return "-"
77
+ size = float(value)
78
+ for unit in ("B", "KB", "MB", "GB", "TB"):
79
+ if size < 1024 or unit == "TB":
80
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
81
+ size /= 1024
82
+ return f"{size:.1f} TB"
83
+
84
+
85
+ def _badge(state: str) -> str:
86
+ css = {"ok": "ok", "degraded": "warn", "failed": "fail", "disabled": "muted"}.get(
87
+ state, "muted"
88
+ )
89
+ return f'<span class="badge {css}">{_e(state)}</span>'
90
+
91
+
92
+ def _event_detail(event: dict[str, Any]) -> str:
93
+ payload = event.get("payload")
94
+ if not isinstance(payload, dict):
95
+ return ""
96
+ kind = str(event.get("kind", ""))
97
+ if kind.startswith("filesystem."):
98
+ path = _e(payload.get("path", ""))
99
+ suffix = " (directory)" if payload.get("is_dir") else f" ({_size(payload.get('size'))})"
100
+ return f"<code>{path}</code>{suffix}"
101
+ if kind.startswith("process."):
102
+ pid = _e(payload.get("pid", "?"))
103
+ name = _e(payload.get("name", "unknown"))
104
+ command = _e(payload.get("command", ""))
105
+ extra = _e(payload.get("reason", ""))
106
+ lifetime = payload.get("observed_lifetime_s")
107
+ suffix = f" · {_duration(lifetime)}" if isinstance(lifetime, (int, float)) else ""
108
+ reason = f" · {extra}" if extra else ""
109
+ command_html = f" <code>{command}</code>" if command else ""
110
+ return f"pid {pid} · {name}{command_html}{reason}{suffix}"
111
+ if kind == "network.connection":
112
+ process = _e(payload.get("process_name", "unknown"))
113
+ pid = _e(payload.get("pid", "?"))
114
+ local = _e(payload.get("local_address") or "-")
115
+ remote = _e(payload.get("remote_address") or "listening")
116
+ protocol = _e(payload.get("protocol", "TCP"))
117
+ status = _e(payload.get("status", "UNKNOWN"))
118
+ return (
119
+ f"{process} (pid {pid}) · <code>{local}</code> → <code>{remote}</code> "
120
+ f"· {protocol} · {status}"
121
+ )
122
+ return ""
123
+
124
+
125
+ def _event_table(events: list[dict[str, Any]]) -> str:
126
+ if not events:
127
+ return '<p class="empty">No events observed in this category.</p>'
128
+ rows = []
129
+ for event in events:
130
+ rows.append(
131
+ "<tr>"
132
+ f"<td><code>{_e(event.get('kind'))}</code></td>"
133
+ f'<td class="muted">{_iso(event.get("occurred_at"))}</td>'
134
+ f"<td>{_event_detail(event)}</td>"
135
+ "</tr>"
136
+ )
137
+ return (
138
+ "<table><thead><tr><th>Event</th><th>Observed at</th><th>Details</th></tr></thead>"
139
+ f"<tbody>{''.join(rows)}</tbody></table>"
140
+ )
141
+
142
+
143
+ def render_html(report: dict[str, Any]) -> str:
144
+ """Render a report dictionary as a standalone HTML document."""
145
+
146
+ session = report.get("session", {})
147
+ summary = report.get("summary", {})
148
+ collectors = report.get("collectors", [])
149
+ events = report.get("events", [])
150
+ warnings = report.get("warnings", [])
151
+ limitations = report.get("limitations", [])
152
+ if not isinstance(session, dict):
153
+ session = {}
154
+ if not isinstance(summary, dict):
155
+ summary = {}
156
+ if not isinstance(events, list):
157
+ events = []
158
+ if not isinstance(collectors, list):
159
+ collectors = []
160
+
161
+ filesystem = [
162
+ event
163
+ for event in events
164
+ if isinstance(event, dict) and str(event.get("kind", "")).startswith("filesystem.")
165
+ ]
166
+ processes = [
167
+ event
168
+ for event in events
169
+ if isinstance(event, dict) and str(event.get("kind", "")).startswith("process.")
170
+ ]
171
+ network = [
172
+ event
173
+ for event in events
174
+ if isinstance(event, dict) and str(event.get("kind", "")).startswith("network.")
175
+ ]
176
+
177
+ collector_rows = []
178
+ for collector in collectors:
179
+ if not isinstance(collector, dict):
180
+ continue
181
+ collector_rows.append(
182
+ "<tr>"
183
+ f"<td>{_e(collector.get('name'))}</td>"
184
+ f"<td>{_badge(str(collector.get('state', 'unknown')))}</td>"
185
+ f"<td>{_e(collector.get('message') or '')}</td>"
186
+ "</tr>"
187
+ )
188
+ collectors_html = (
189
+ "<table><thead><tr><th>Collector</th><th>State</th><th>Message</th></tr></thead>"
190
+ f"<tbody>{''.join(collector_rows)}</tbody></table>"
191
+ if collector_rows
192
+ else '<p class="empty">No collector status recorded.</p>'
193
+ )
194
+
195
+ warning_html = "".join(f"<li>{_e(item)}</li>" for item in warnings)
196
+ limitation_html = "".join(f"<li>{_e(item)}</li>" for item in limitations)
197
+ events_total = summary.get("events_total", len(events))
198
+ shown_note = ""
199
+ if report.get("events_shown") is not None and len(events) < int(events_total or 0):
200
+ shown_note = (
201
+ f'<p class="muted">Showing {len(events)} of {_e(events_total)} events. '
202
+ "Use --all to include every event.</p>"
203
+ )
204
+
205
+ environment_html = ""
206
+ if "environment_diff" in session:
207
+ environment_html = (
208
+ "<h2>Environment (initial child diff, sanitized)</h2>"
209
+ f"<pre>{_e(json.dumps(session.get('environment_diff'), indent=2, ensure_ascii=False))}</pre>"
210
+ )
211
+
212
+ options = session.get("options")
213
+ watch_roots = "-"
214
+ if isinstance(options, dict) and isinstance(options.get("watch_roots"), list):
215
+ watch_roots = ", ".join(str(item) for item in options["watch_roots"])
216
+
217
+ title = f"CmdScope report {session.get('ref', '')}".strip()
218
+ return f"""<!doctype html>
219
+ <html lang="en">
220
+ <head>
221
+ <meta charset="utf-8">
222
+ <meta name="viewport" content="width=device-width, initial-scale=1">
223
+ <title>{_e(title)}</title>
224
+ <style>{_CSS}</style>
225
+ </head>
226
+ <body>
227
+ <main>
228
+ <h1>CmdScope execution report</h1>
229
+ <p class="sub">Session {_e(session.get("ref"))} · generated {_iso(report.get("generated_at"))}</p>
230
+
231
+ <div class="cards">
232
+ <div class="card"><div class="num">{_e(summary.get("filesystem_created"))}</div><div class="label">Files created</div></div>
233
+ <div class="card"><div class="num">{_e(summary.get("filesystem_modified"))}</div><div class="label">Files modified</div></div>
234
+ <div class="card"><div class="num">{_e(summary.get("filesystem_deleted"))}</div><div class="label">Files deleted</div></div>
235
+ <div class="card"><div class="num">{_e(summary.get("processes_started"))}</div><div class="label">Processes started</div></div>
236
+ <div class="card"><div class="num">{_e(summary.get("network_connections"))}</div><div class="label">Network endpoints</div></div>
237
+ </div>
238
+
239
+ <h2>Session</h2>
240
+ <div class="panel">
241
+ <table>
242
+ <tr><th>Command</th><td><code>{_e(session.get("command_display"))}</code></td></tr>
243
+ <tr><th>Status</th><td>{_badge(str(session.get("status", "unknown")))}</td></tr>
244
+ <tr><th>Exit status</th><td>{_e(session.get("exit_code") if session.get("exit_code") is not None else "-")}</td></tr>
245
+ <tr><th>Directory</th><td><code>{_e(session.get("cwd"))}</code></td></tr>
246
+ <tr><th>Platform</th><td>{_e(session.get("platform"))}</td></tr>
247
+ <tr><th>Started</th><td>{_iso(session.get("started_at") or session.get("created_at"))}</td></tr>
248
+ <tr><th>Duration</th><td>{_duration(session.get("duration_s"))}</td></tr>
249
+ <tr><th>Watch roots</th><td><code>{_e(watch_roots)}</code></td></tr>
250
+ {"<tr><th>Error</th><td><code>" + _e(session.get("error")) + "</code></td></tr>" if session.get("error") else ""}
251
+ </table>
252
+ </div>
253
+
254
+ <h2>Filesystem</h2>
255
+ <div class="panel">{_event_table(filesystem)}</div>
256
+
257
+ <h2>Processes</h2>
258
+ <div class="panel">{_event_table(processes)}</div>
259
+
260
+ <h2>Network</h2>
261
+ <div class="panel">{_event_table(network)}</div>
262
+
263
+ <h2>Collectors</h2>
264
+ <div class="panel">{collectors_html}</div>
265
+
266
+ {environment_html}
267
+
268
+ <h2>Limitations</h2>
269
+ <div class="panel"><ul>{limitation_html}</ul></div>
270
+
271
+ <h2>Warnings</h2>
272
+ <div class="panel"><ul>{warning_html or "<li>None recorded.</li>"}</ul></div>
273
+
274
+ {shown_note}
275
+ <footer>Generated locally by CmdScope. No data was transmitted. CmdScope observes; it does not sandbox.</footer>
276
+ </main>
277
+ </body>
278
+ </html>
279
+ """
@@ -0,0 +1,133 @@
1
+ """JSON report builder."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Sequence
7
+ from datetime import UTC, datetime
8
+ from typing import Any
9
+
10
+ from cmdscope.analysis.comparison import ComparisonResult
11
+ from cmdscope.core.events import EventKind, StoredEvent
12
+ from cmdscope.core.session import Session, SessionSummary
13
+
14
+ REPORT_SCHEMA = "cmdscope.report/v1"
15
+
16
+
17
+ def _session_dict(session: Session, *, include_environment: bool) -> dict[str, Any]:
18
+ result: dict[str, Any] = {
19
+ "id": session.id,
20
+ "ref": session.ref,
21
+ "status": str(session.status),
22
+ "command": list(session.command),
23
+ "command_display": session.command_display,
24
+ "cwd": session.cwd,
25
+ "platform": session.platform_name,
26
+ "created_at": session.created_at.isoformat(),
27
+ "started_at": session.started_at.isoformat() if session.started_at else None,
28
+ "ended_at": session.ended_at.isoformat() if session.ended_at else None,
29
+ "duration_s": session.duration_s,
30
+ "exit_code": session.exit_code,
31
+ "interrupted": session.interrupted,
32
+ "error": session.error,
33
+ "options": session.options,
34
+ }
35
+ if include_environment:
36
+ result["environment_diff"] = session.env_diff
37
+ return result
38
+
39
+
40
+ def _event_dict(event: StoredEvent) -> dict[str, Any]:
41
+ return {
42
+ "id": event.id,
43
+ "kind": str(event.kind),
44
+ "occurred_at": event.occurred_at.isoformat(),
45
+ "source": event.source,
46
+ "payload": event.payload,
47
+ }
48
+
49
+
50
+ def build_report(
51
+ session: Session,
52
+ summary: SessionSummary,
53
+ events: Sequence[StoredEvent],
54
+ *,
55
+ include_environment: bool = False,
56
+ event_limit: int | None = None,
57
+ generated_at: datetime | None = None,
58
+ ) -> dict[str, Any]:
59
+ """Build a stable JSON-friendly report document."""
60
+
61
+ selected = list(events if event_limit is None else events[:event_limit])
62
+ return {
63
+ "schema": REPORT_SCHEMA,
64
+ "generated_at": (generated_at or datetime.now(UTC)).isoformat(),
65
+ "session": _session_dict(session, include_environment=include_environment),
66
+ "summary": {
67
+ "events_total": summary.events_total,
68
+ "filesystem_created": summary.filesystem_created,
69
+ "filesystem_modified": summary.filesystem_modified,
70
+ "filesystem_deleted": summary.filesystem_deleted,
71
+ "processes_started": summary.processes_started,
72
+ "processes_finished": summary.processes_finished,
73
+ "processes_still_running": summary.processes_still_running,
74
+ "network_connections": summary.network_connections,
75
+ },
76
+ "collectors": [collector.to_dict() for collector in session.collectors],
77
+ "events": [_event_dict(event) for event in selected],
78
+ "events_shown": len(selected),
79
+ "limitations": [
80
+ "Only changes under the configured watch roots are observed.",
81
+ "Processes and network sockets are sampled; short-lived ones can be missed.",
82
+ "File contents are never captured.",
83
+ "CmdScope is an observability tool, not a sandbox or isolation boundary.",
84
+ "Runtime environment mutations are not observable externally.",
85
+ ],
86
+ "warnings": list(summary.warnings),
87
+ }
88
+
89
+
90
+ def render_json(report: dict[str, Any]) -> str:
91
+ """Serialize a report deterministically for terminals and files."""
92
+
93
+ return json.dumps(report, indent=2, ensure_ascii=False, sort_keys=False) + "\n"
94
+
95
+
96
+ def build_comparison_report(result: ComparisonResult) -> dict[str, Any]:
97
+ """Build the JSON document used by ``cmdscope compare --format json``."""
98
+
99
+ def item_dict(item: Any) -> dict[str, str]:
100
+ return {
101
+ "category": item.category,
102
+ "action": item.action,
103
+ "label": item.label,
104
+ }
105
+
106
+ return {
107
+ "schema": "cmdscope.comparison/v1",
108
+ "generated_at": datetime.now(UTC).isoformat(),
109
+ "session_a": {
110
+ "id": result.session_a.id,
111
+ "ref": result.session_a.ref,
112
+ "command": result.session_a.command_display,
113
+ "events_total": result.events_a,
114
+ },
115
+ "session_b": {
116
+ "id": result.session_b.id,
117
+ "ref": result.session_b.ref,
118
+ "command": result.session_b.command_display,
119
+ "events_total": result.events_b,
120
+ },
121
+ "common_count": result.common_count,
122
+ "only_in_a": [item_dict(item) for item in result.only_in_a],
123
+ "only_in_b": [item_dict(item) for item in result.only_in_b],
124
+ "note": "Comparison uses normalized paths and event signatures; timestamps are ignored.",
125
+ }
126
+
127
+
128
+ def render_comparison_json(result: ComparisonResult) -> str:
129
+ return json.dumps(build_comparison_report(result), indent=2, ensure_ascii=False) + "\n"
130
+
131
+
132
+ def is_network_event(event: StoredEvent) -> bool:
133
+ return event.kind == EventKind.NETWORK_CONNECTION