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.
@@ -0,0 +1,429 @@
1
+ """Plain-text terminal reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections import Counter
7
+ from collections.abc import Iterable, Sequence
8
+ from datetime import UTC, datetime
9
+ from typing import TypeVar
10
+
11
+ from cmdscope.analysis.comparison import ComparisonItem, ComparisonResult
12
+ from cmdscope.core.events import CollectorState, EventKind, StoredEvent
13
+ from cmdscope.core.session import Session, SessionSummary
14
+
15
+ T = TypeVar("T")
16
+
17
+ RULE = "=" * 70
18
+ _BOLD_CYAN = "1;36"
19
+ _BOLD = "1"
20
+ _GREEN = "32"
21
+ _YELLOW = "33"
22
+ _RED = "31"
23
+ _DIM = "2"
24
+
25
+
26
+ def _style(text: str, code: str, enabled: bool) -> str:
27
+ if not enabled:
28
+ return text
29
+ return f"\033[{code}m{text}\033[0m"
30
+
31
+
32
+ def _heading(text: str, color: bool) -> str:
33
+ return _style(text, _BOLD_CYAN, color)
34
+
35
+
36
+ def _fmt_time(value: datetime | None) -> str:
37
+ if value is None:
38
+ return "-"
39
+ return value.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
40
+
41
+
42
+ def _fmt_duration(value: float | None) -> str:
43
+ if value is None:
44
+ return "-"
45
+ if value < 1:
46
+ return f"{value * 1000:.0f} ms"
47
+ return f"{value:.2f} s"
48
+
49
+
50
+ def _fmt_size(value: object) -> str:
51
+ if not isinstance(value, int):
52
+ return "-"
53
+ size = float(value)
54
+ for unit in ("B", "KB", "MB", "GB", "TB"):
55
+ if size < 1024 or unit == "TB":
56
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
57
+ size /= 1024
58
+ return f"{size:.1f} TB"
59
+
60
+
61
+ def _short_path(path: str, cwd: str) -> str:
62
+ try:
63
+ relative = os.path.relpath(path, cwd)
64
+ except ValueError:
65
+ relative = path
66
+ if relative.startswith(".."):
67
+ relative = path
68
+ if len(relative) > 110:
69
+ relative = "..." + relative[-107:]
70
+ return relative
71
+
72
+
73
+ def _limited(
74
+ items: Sequence[T],
75
+ limit: int | None,
76
+ ) -> tuple[list[T], int]:
77
+ if limit is None:
78
+ return list(items), 0
79
+ return list(items[:limit]), max(0, len(items) - limit)
80
+
81
+
82
+ def _status_label(state: CollectorState) -> str:
83
+ return str(state)
84
+
85
+
86
+ def _event_symbol(kind: EventKind) -> str:
87
+ return {
88
+ EventKind.FILESYSTEM_CREATED: "+",
89
+ EventKind.FILESYSTEM_MODIFIED: "~",
90
+ EventKind.FILESYSTEM_DELETED: "-",
91
+ }.get(kind, "?")
92
+
93
+
94
+ def _event_color(kind: EventKind) -> str:
95
+ return {
96
+ EventKind.FILESYSTEM_CREATED: _GREEN,
97
+ EventKind.FILESYSTEM_MODIFIED: _YELLOW,
98
+ EventKind.FILESYSTEM_DELETED: _RED,
99
+ }.get(kind, _DIM)
100
+
101
+
102
+ def _filesystem_lines(
103
+ events: list[StoredEvent],
104
+ *,
105
+ cwd: str,
106
+ limit: int | None,
107
+ color: bool,
108
+ ) -> list[str]:
109
+ if not events:
110
+ return [" (no filesystem events observed)"]
111
+ selected, hidden = _limited(events, limit)
112
+ lines = []
113
+ for event in selected:
114
+ payload = event.payload
115
+ path = str(payload.get("path", ""))
116
+ is_dir = bool(payload.get("is_dir", False))
117
+ display = _short_path(path, cwd) + ("/" if is_dir else "")
118
+ size = "" if is_dir else f" ({_fmt_size(payload.get('size'))})"
119
+ symbol = _style(_event_symbol(event.kind), _event_color(event.kind), color)
120
+ lines.append(f" {symbol} {display}{size}")
121
+ if hidden:
122
+ lines.append(f" ... {hidden} more filesystem events (use --all to show)")
123
+ return lines
124
+
125
+
126
+ def _process_lines(events: list[StoredEvent], *, limit: int | None) -> list[str]:
127
+ if not events:
128
+ return [" (no process events observed)"]
129
+ selected, hidden = _limited(events, limit)
130
+ lines = []
131
+ for event in selected:
132
+ payload = event.payload
133
+ pid = payload.get("pid", "?")
134
+ name = payload.get("name", "unknown")
135
+ command = str(payload.get("command", "") or name)
136
+ if len(command) > 100:
137
+ command = command[:97] + "..."
138
+ if event.kind == EventKind.PROCESS_STARTED:
139
+ lines.append(f" [pid {pid}] started: {command}")
140
+ elif event.kind == EventKind.PROCESS_FINISHED:
141
+ reason = payload.get("reason", "finished")
142
+ lines.append(f" [pid {pid}] finished: {name} ({reason})")
143
+ else:
144
+ lines.append(f" [pid {pid}] still running: {command}")
145
+ if hidden:
146
+ lines.append(f" ... {hidden} more process events (use --all to show)")
147
+ return lines
148
+
149
+
150
+ def _network_lines(events: list[StoredEvent], *, limit: int | None) -> list[str]:
151
+ if not events:
152
+ return [" (no network endpoints observed)"]
153
+ selected, hidden = _limited(events, limit)
154
+ lines = []
155
+ for event in selected:
156
+ payload = event.payload
157
+ protocol = payload.get("protocol", "TCP")
158
+ status = payload.get("status", "UNKNOWN")
159
+ local = payload.get("local_address") or "-"
160
+ remote = payload.get("remote_address") or "listening"
161
+ process = payload.get("process_name", "unknown")
162
+ pid = payload.get("pid", "?")
163
+ lines.append(f" [pid {pid}] {process}: {local} -> {remote} ({protocol}, {status})")
164
+ if hidden:
165
+ lines.append(f" ... {hidden} more network events (use --all to show)")
166
+ return lines
167
+
168
+
169
+ def _environment_lines(session: Session) -> list[str]:
170
+ diff = session.env_diff
171
+ added = diff.get("added")
172
+ removed = diff.get("removed")
173
+ changed = diff.get("changed")
174
+ lines: list[str] = []
175
+ if isinstance(added, dict):
176
+ for added_key in sorted(added):
177
+ lines.append(f" + {added_key}={_truncate_value(added[added_key])}")
178
+ if isinstance(removed, list):
179
+ for removed_key in removed:
180
+ lines.append(f" - {removed_key!s}")
181
+ if isinstance(changed, dict):
182
+ for changed_key in sorted(changed):
183
+ detail = changed[changed_key]
184
+ if isinstance(detail, dict):
185
+ before = _truncate_value(detail.get("before"))
186
+ after = _truncate_value(detail.get("after"))
187
+ lines.append(f" ~ {changed_key}: {before} -> {after}")
188
+ else:
189
+ lines.append(f" ~ {changed_key}")
190
+ return lines or [" (no initial environment differences; runtime mutations are not observable)"]
191
+
192
+
193
+ def _truncate_value(value: object, limit: int = 80) -> str:
194
+ text = str(value)
195
+ if len(text) > limit:
196
+ return text[: limit - 3] + "..."
197
+ return text
198
+
199
+
200
+ def render_session_report(
201
+ session: Session,
202
+ summary: SessionSummary,
203
+ events: Sequence[StoredEvent],
204
+ *,
205
+ limit: int | None = 50,
206
+ include_environment: bool = False,
207
+ color: bool = False,
208
+ ) -> str:
209
+ """Render a complete, human-readable report for one session."""
210
+
211
+ started = session.started_at or session.created_at
212
+ watch_roots = session.options.get("watch_roots")
213
+ if isinstance(watch_roots, list):
214
+ roots_text = ", ".join(str(root) for root in watch_roots)
215
+ else:
216
+ roots_text = "-"
217
+
218
+ lines = [
219
+ _heading("CMDSCOPE - EXECUTION REPORT", color),
220
+ RULE,
221
+ f"Session: {session.ref}",
222
+ f"Command: {session.command_display or '(unknown)'}",
223
+ f"Directory: {session.cwd}",
224
+ f"Platform: {session.platform_name}",
225
+ f"Started: {_fmt_time(started)}",
226
+ f"Duration: {_fmt_duration(session.duration_s)}",
227
+ f"Exit status: {session.exit_code if session.exit_code is not None else '-'}",
228
+ f"Status: {session.status}",
229
+ ]
230
+ if session.interrupted:
231
+ lines.append("Interrupted: yes")
232
+ if session.error:
233
+ lines.append(f"Error: {session.error}")
234
+ lines.append("")
235
+
236
+ lines.append(_heading("FILESYSTEM", color))
237
+ lines.append(f" Watch roots: {roots_text}")
238
+ lines.append(f" Created: {summary.filesystem_created}")
239
+ lines.append(f" Modified: {summary.filesystem_modified}")
240
+ lines.append(f" Deleted: {summary.filesystem_deleted}")
241
+ filesystem_events = [
242
+ event
243
+ for event in events
244
+ if event.kind
245
+ in {
246
+ EventKind.FILESYSTEM_CREATED,
247
+ EventKind.FILESYSTEM_MODIFIED,
248
+ EventKind.FILESYSTEM_DELETED,
249
+ }
250
+ ]
251
+ lines.extend(_filesystem_lines(filesystem_events, cwd=session.cwd, limit=limit, color=color))
252
+ lines.append("")
253
+
254
+ lines.append(_heading("PROCESSES", color))
255
+ lines.append(f" Started: {summary.processes_started}")
256
+ lines.append(f" Finished: {summary.processes_finished}")
257
+ lines.append(f" Still running: {summary.processes_still_running}")
258
+ process_events = [
259
+ event
260
+ for event in events
261
+ if event.kind
262
+ in {
263
+ EventKind.PROCESS_STARTED,
264
+ EventKind.PROCESS_FINISHED,
265
+ EventKind.PROCESS_STILL_RUNNING,
266
+ }
267
+ ]
268
+ lines.extend(_process_lines(process_events, limit=limit))
269
+ lines.append("")
270
+
271
+ lines.append(_heading("NETWORK", color))
272
+ lines.append(f" Connections observed: {summary.network_connections}")
273
+ network_events = [event for event in events if event.kind == EventKind.NETWORK_CONNECTION]
274
+ lines.extend(_network_lines(network_events, limit=limit))
275
+ lines.append("")
276
+
277
+ lines.append(_heading("COLLECTORS", color))
278
+ if not session.collectors:
279
+ lines.append(" (no collector status recorded)")
280
+ for collector in session.collectors:
281
+ message = f" {collector.message}" if collector.message else ""
282
+ lines.append(f" {collector.name:<12} {_status_label(collector.state):<9}{message}")
283
+ details = collector.details
284
+ if details:
285
+ compact = ", ".join(f"{key}={value}" for key, value in sorted(details.items()))
286
+ if len(compact) > 150:
287
+ compact = compact[:147] + "..."
288
+ lines.append(f" {compact}")
289
+ lines.append("")
290
+
291
+ if include_environment:
292
+ lines.append(_heading("ENVIRONMENT (initial child diff, sanitized)", color))
293
+ lines.extend(_environment_lines(session))
294
+ lines.append("")
295
+
296
+ lines.append(_heading("SUMMARY", color))
297
+ lines.append(f" {summary.events_total} events observed in this session.")
298
+ lines.append(
299
+ f" {summary.filesystem_created + summary.filesystem_modified + summary.filesystem_deleted} "
300
+ "filesystem changes, "
301
+ f"{summary.processes_started} processes started, "
302
+ f"{summary.network_connections} network connections."
303
+ )
304
+ lines.append("")
305
+
306
+ lines.append(_heading("LIMITATIONS", color))
307
+ lines.append(" - Only changes under the configured watch roots are observed.")
308
+ lines.append(" - Processes and sockets are sampled; short-lived ones can be missed.")
309
+ lines.append(" - File contents are never captured.")
310
+ lines.append(" - CmdScope observability is not sandboxing or isolation.")
311
+ lines.append(" - Runtime environment mutations are not observable externally.")
312
+ for warning in summary.warnings:
313
+ lines.append(f" - {warning}")
314
+ lines.append("")
315
+ lines.append("Generated locally by CmdScope. No data was transmitted.")
316
+ return "\n".join(lines)
317
+
318
+
319
+ def render_sessions(sessions: Iterable[Session], *, color: bool = False) -> str:
320
+ """Render a compact session-history table."""
321
+
322
+ items = list(sessions)
323
+ if not items:
324
+ return "No sessions recorded yet.\n\nRun one with: cmdscope run -- <command>\n"
325
+
326
+ headers = ("ID", "STARTED (UTC)", "DURATION", "EXIT", "STATUS", "COMMAND")
327
+ rows: list[tuple[str, ...]] = []
328
+ for session in items:
329
+ command = session.command_display or "(unknown)"
330
+ if len(command) > 60:
331
+ command = command[:57] + "..."
332
+ rows.append(
333
+ (
334
+ session.ref,
335
+ _fmt_time(session.started_at or session.created_at).replace(" UTC", ""),
336
+ _fmt_duration(session.duration_s),
337
+ str(session.exit_code) if session.exit_code is not None else "-",
338
+ str(session.status),
339
+ command,
340
+ )
341
+ )
342
+ widths = [
343
+ max(len(headers[index]), *(len(row[index]) for row in rows))
344
+ for index in range(len(headers))
345
+ ]
346
+ lines = [
347
+ _heading("CMDSCOPE - SESSIONS", color),
348
+ RULE,
349
+ " ".join(header.ljust(widths[index]) for index, header in enumerate(headers)),
350
+ " ".join("-" * width for width in widths),
351
+ ]
352
+ for row in rows:
353
+ lines.append(" ".join(value.ljust(widths[index]) for index, value in enumerate(row)))
354
+ return "\n".join(lines)
355
+
356
+
357
+ def _group_items(items: Sequence[ComparisonItem]) -> dict[tuple[str, str], list[str]]:
358
+ grouped: dict[tuple[str, str], list[str]] = {}
359
+ for item in items:
360
+ grouped.setdefault((item.category, item.action), []).append(item.label)
361
+ return grouped
362
+
363
+
364
+ def _comparison_section(
365
+ title: str,
366
+ items: Sequence[ComparisonItem],
367
+ *,
368
+ limit: int | None,
369
+ ) -> list[str]:
370
+ lines = [title]
371
+ if not items:
372
+ lines.append(" (none)")
373
+ return lines
374
+ grouped = _group_items(items)
375
+ shown = 0
376
+ for (category, action), labels in sorted(grouped.items()):
377
+ lines.append(f" {category} {action}: {len(labels)}")
378
+ for label in labels:
379
+ if limit is not None and shown >= limit:
380
+ break
381
+ lines.append(f" {label}")
382
+ shown += 1
383
+ if limit is not None and shown >= limit:
384
+ break
385
+ if limit is not None and len(items) > shown:
386
+ lines.append(f" ... {len(items) - shown} more differences (use --all to show)")
387
+ return lines
388
+
389
+
390
+ def render_comparison(
391
+ result: ComparisonResult,
392
+ *,
393
+ limit: int | None = 50,
394
+ color: bool = False,
395
+ ) -> str:
396
+ """Render differences between two observed sessions."""
397
+
398
+ lines = [
399
+ _heading("CMDSCOPE - SESSION COMPARISON", color),
400
+ RULE,
401
+ f"A: {result.session_a.ref} {result.session_a.command_display}",
402
+ f" {result.events_a} events observed",
403
+ f"B: {result.session_b.ref} {result.session_b.command_display}",
404
+ f" {result.events_b} events observed",
405
+ "",
406
+ _heading("SUMMARY", color),
407
+ f" Shared observations: {result.common_count}",
408
+ f" Only in A: {len(result.only_in_a)}",
409
+ f" Only in B: {len(result.only_in_b)}",
410
+ "",
411
+ ]
412
+ lines.extend(_comparison_section("ONLY IN A", result.only_in_a, limit=limit))
413
+ lines.append("")
414
+ lines.extend(_comparison_section("ONLY IN B", result.only_in_b, limit=limit))
415
+ lines.append("")
416
+ if not result.only_in_a and not result.only_in_b:
417
+ lines.append("No differences in observed events.")
418
+ lines.append("")
419
+ lines.append("Comparison uses normalized paths and event signatures; timestamps are ignored.")
420
+ return "\n".join(lines)
421
+
422
+
423
+ def category_counts(events: Sequence[StoredEvent]) -> Counter[str]:
424
+ """Small helper used by reports/tests to summarize categories."""
425
+
426
+ counts: Counter[str] = Counter()
427
+ for event in events:
428
+ counts[str(event.kind).split(".", 1)[0]] += 1
429
+ return counts
@@ -0,0 +1,6 @@
1
+ """Storage package."""
2
+
3
+ from cmdscope.storage.sqlite import Storage
4
+ from cmdscope.storage.writer import EventWriter, EventWriterStats
5
+
6
+ __all__ = ["EventWriter", "EventWriterStats", "Storage"]