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,514 @@
1
+ """Session runner: coordinate collectors, child process, storage, and cleanup."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import signal
8
+ import subprocess
9
+ import time
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ import psutil
16
+
17
+ from cmdscope import __version__
18
+ from cmdscope.analysis.summary import summarize
19
+ from cmdscope.collectors.filesystem import FilesystemCollector
20
+ from cmdscope.collectors.network import NetworkCollector
21
+ from cmdscope.collectors.processes import ProcessCollector
22
+ from cmdscope.core.collector import Collector, CollectorContext
23
+ from cmdscope.core.events import (
24
+ CollectorState,
25
+ CollectorStatus,
26
+ SessionStatus,
27
+ utc_now,
28
+ )
29
+ from cmdscope.core.ignore import IgnoreRules
30
+ from cmdscope.core.registry import ProcessRegistry
31
+ from cmdscope.core.session import RunResult, SessionSummary
32
+ from cmdscope.errors import CommandLaunchError, StorageError
33
+ from cmdscope.platforms import current_platform_info, prepare_command
34
+ from cmdscope.privacy import environment_diff, format_command, sanitize_command
35
+ from cmdscope.storage.sqlite import Storage
36
+ from cmdscope.storage.writer import EventWriter, EventWriterStats
37
+
38
+
39
+ @dataclass(frozen=True, slots=True)
40
+ class RunOptions:
41
+ """Fully resolved options for one monitored execution."""
42
+
43
+ command: tuple[str, ...]
44
+ cwd: Path
45
+ watch_roots: tuple[Path, ...]
46
+ ignore_patterns: tuple[str, ...]
47
+ env_overrides: Mapping[str, str]
48
+ database: Path
49
+ enable_filesystem: bool = True
50
+ enable_processes: bool = True
51
+ enable_network: bool = True
52
+ filesystem_poll_interval: float = 1.0
53
+ process_poll_interval: float = 0.5
54
+ network_poll_interval: float = 0.25
55
+ max_files: int = 100_000
56
+ max_events: int = 100_000
57
+ max_sessions: int = 0
58
+ use_native_events: bool = True
59
+ capture_environment: bool = True
60
+
61
+
62
+ def execute(
63
+ options: RunOptions,
64
+ *,
65
+ environment: Mapping[str, str] | None = None,
66
+ ) -> RunResult:
67
+ """Run one command and persist a complete session."""
68
+
69
+ parent_environment = dict(os.environ if environment is None else environment)
70
+ child_environment = dict(parent_environment)
71
+ child_environment.update(options.env_overrides)
72
+ env_diff = (
73
+ environment_diff(parent_environment, child_environment)
74
+ if options.capture_environment
75
+ else {}
76
+ )
77
+
78
+ sanitized = sanitize_command(options.command)
79
+ display = format_command(sanitized)
80
+ platform_info = current_platform_info()
81
+ database = options.database.expanduser().resolve()
82
+ storage = Storage(database)
83
+ database_files = (
84
+ database,
85
+ Path(f"{database}-wal"),
86
+ Path(f"{database}-shm"),
87
+ Path(f"{database}-journal"),
88
+ )
89
+ ignore = IgnoreRules(options.ignore_patterns, exclude_paths=database_files)
90
+
91
+ session_id: int | None = None
92
+ writer: EventWriter | None = None
93
+ statuses: list[CollectorStatus] = []
94
+ active: list[Collector] = []
95
+ process: subprocess.Popen[bytes] | None = None
96
+ finalized = False
97
+
98
+ def add_disabled(name: str) -> None:
99
+ if name not in {status.name for status in statuses}:
100
+ statuses.append(
101
+ CollectorStatus(
102
+ name=name,
103
+ state=CollectorState.DISABLED,
104
+ message="disabled by configuration",
105
+ )
106
+ )
107
+
108
+ try:
109
+ session_id = storage.create_session(
110
+ command=sanitized,
111
+ command_display=display,
112
+ cwd=str(options.cwd),
113
+ platform_name=platform_info.name,
114
+ created_at=utc_now(),
115
+ env_diff=env_diff,
116
+ options={
117
+ "cmdscope_version": __version__,
118
+ "watch_roots": [str(root) for root in options.watch_roots],
119
+ "ignore": list(options.ignore_patterns),
120
+ "filesystem": {
121
+ "enabled": options.enable_filesystem,
122
+ "native_events": options.use_native_events,
123
+ "poll_interval": options.filesystem_poll_interval,
124
+ "max_files": options.max_files,
125
+ },
126
+ "processes": {
127
+ "enabled": options.enable_processes,
128
+ "poll_interval": options.process_poll_interval,
129
+ },
130
+ "network": {
131
+ "enabled": options.enable_network,
132
+ "poll_interval": options.network_poll_interval,
133
+ },
134
+ "storage": {"max_events": options.max_events},
135
+ },
136
+ )
137
+
138
+ writer = EventWriter(database, session_id, options.max_events)
139
+ writer.start()
140
+ registry = ProcessRegistry()
141
+
142
+ def context(root_pid: int) -> CollectorContext:
143
+ assert writer is not None
144
+ return CollectorContext(
145
+ session_id=session_id or 0,
146
+ watch_roots=options.watch_roots,
147
+ ignore=ignore,
148
+ root_pid=root_pid,
149
+ command_name=Path(options.command[0]).name,
150
+ registry=registry,
151
+ emit=writer.emit,
152
+ filesystem_poll_interval=options.filesystem_poll_interval,
153
+ process_poll_interval=options.process_poll_interval,
154
+ network_poll_interval=options.network_poll_interval,
155
+ max_files=options.max_files,
156
+ use_native_events=options.use_native_events,
157
+ )
158
+
159
+ if options.enable_filesystem:
160
+ filesystem = FilesystemCollector(
161
+ max_files=options.max_files,
162
+ poll_interval=options.filesystem_poll_interval,
163
+ use_native_events=options.use_native_events,
164
+ )
165
+ try:
166
+ filesystem.start(context(0))
167
+ active.append(filesystem)
168
+ except Exception as exc:
169
+ statuses.append(
170
+ CollectorStatus(
171
+ name=filesystem.name,
172
+ state=CollectorState.FAILED,
173
+ message=f"collector failed to start: {exc}",
174
+ )
175
+ )
176
+ else:
177
+ add_disabled("filesystem")
178
+
179
+ if not options.enable_processes:
180
+ add_disabled("processes")
181
+ if not options.enable_network:
182
+ add_disabled("network")
183
+
184
+ storage.mark_command_started(session_id, utc_now())
185
+ prepared = prepare_command(options.command, child_environment)
186
+ spawn_started = time.monotonic()
187
+ try:
188
+ process = _spawn(prepared, options.cwd, child_environment)
189
+ except OSError as exc:
190
+ error = f"could not start command {options.command[0]!r}: {exc}"
191
+ exit_code = 127 if isinstance(exc, FileNotFoundError) else 126
192
+ _finalize(
193
+ storage,
194
+ session_id,
195
+ status=SessionStatus.FAILED,
196
+ ended_at=utc_now(),
197
+ duration_s=0.0,
198
+ exit_code=exit_code,
199
+ interrupted=False,
200
+ error=error,
201
+ statuses=statuses,
202
+ active=active,
203
+ writer=writer,
204
+ event_limit=options.max_events,
205
+ )
206
+ finalized = True
207
+ raise CommandLaunchError(error, exit_code=exit_code, session_id=session_id) from exc
208
+
209
+ root_pid = process.pid
210
+ if options.enable_processes:
211
+ process_collector = ProcessCollector(poll_interval=options.process_poll_interval)
212
+ process_collector.set_registry(registry)
213
+ try:
214
+ process_collector.start(context(root_pid))
215
+ active.append(process_collector)
216
+ except Exception as exc:
217
+ statuses.append(
218
+ CollectorStatus(
219
+ name=process_collector.name,
220
+ state=CollectorState.FAILED,
221
+ message=f"collector failed to start: {exc}",
222
+ )
223
+ )
224
+ registry.add(root_pid, Path(options.command[0]).name)
225
+ else:
226
+ registry.add(root_pid, Path(options.command[0]).name)
227
+
228
+ if options.enable_network:
229
+ network_collector = NetworkCollector(poll_interval=options.network_poll_interval)
230
+ try:
231
+ network_collector.start(context(root_pid))
232
+ active.append(network_collector)
233
+ except Exception as exc:
234
+ statuses.append(
235
+ CollectorStatus(
236
+ name=network_collector.name,
237
+ state=CollectorState.FAILED,
238
+ message=f"collector failed to start: {exc}",
239
+ )
240
+ )
241
+
242
+ exit_code, interrupted = _wait_for_process(process)
243
+ ended_at = utc_now()
244
+ duration = max(0.0, time.monotonic() - spawn_started)
245
+
246
+ for collector in reversed(active):
247
+ statuses.append(_stop_collector(collector))
248
+
249
+ writer_stats, writer_error = _close_writer(writer)
250
+ if writer_stats is not None and writer_stats.truncated:
251
+ statuses.append(
252
+ CollectorStatus(
253
+ name="event-writer",
254
+ state=CollectorState.DEGRADED,
255
+ message=(
256
+ f"event limit of {options.max_events} reached; "
257
+ f"{writer_stats.dropped} events were not stored"
258
+ ),
259
+ details={
260
+ "written": writer_stats.written,
261
+ "dropped": writer_stats.dropped,
262
+ },
263
+ )
264
+ )
265
+ if writer_error:
266
+ statuses.append(
267
+ CollectorStatus(
268
+ name="event-writer",
269
+ state=CollectorState.FAILED,
270
+ message=writer_error,
271
+ )
272
+ )
273
+
274
+ if writer_error:
275
+ session_status = SessionStatus.FAILED
276
+ session_error = writer_error
277
+ effective_exit = 1
278
+ elif interrupted:
279
+ session_status = SessionStatus.INTERRUPTED
280
+ session_error = None
281
+ effective_exit = exit_code
282
+ else:
283
+ session_status = SessionStatus.COMPLETED
284
+ session_error = None
285
+ effective_exit = exit_code
286
+
287
+ storage.finish_session(
288
+ session_id,
289
+ status=session_status,
290
+ ended_at=ended_at,
291
+ duration_s=duration,
292
+ exit_code=exit_code,
293
+ interrupted=interrupted,
294
+ error=session_error,
295
+ )
296
+ storage.set_collector_statuses(session_id, statuses)
297
+ finalized = True
298
+
299
+ try:
300
+ storage.prune(options.max_sessions)
301
+ except StorageError as exc:
302
+ statuses.append(
303
+ CollectorStatus(
304
+ name="storage",
305
+ state=CollectorState.DEGRADED,
306
+ message=f"session pruning failed: {exc}",
307
+ )
308
+ )
309
+ storage.set_collector_statuses(session_id, statuses)
310
+
311
+ session = storage.require_session(str(session_id))
312
+ events = storage.get_events(session_id)
313
+ summary = summarize(session, events)
314
+ return RunResult(
315
+ session_id=session_id,
316
+ exit_code=effective_exit,
317
+ interrupted=interrupted,
318
+ event_count=len(events),
319
+ events_truncated=bool(writer_stats and writer_stats.truncated),
320
+ summary=summary,
321
+ )
322
+ except CommandLaunchError:
323
+ raise
324
+ except Exception as exc:
325
+ if session_id is not None and not finalized:
326
+ try:
327
+ for collector in reversed(active):
328
+ statuses.append(_stop_collector(collector))
329
+ _finalize(
330
+ storage,
331
+ session_id,
332
+ status=SessionStatus.FAILED,
333
+ ended_at=utc_now(),
334
+ duration_s=0.0,
335
+ exit_code=1,
336
+ interrupted=False,
337
+ error=f"internal error: {exc}",
338
+ statuses=statuses,
339
+ active=[],
340
+ writer=writer,
341
+ event_limit=options.max_events,
342
+ )
343
+ except Exception:
344
+ pass
345
+ if process is not None and process.poll() is None:
346
+ _terminate_tree(process)
347
+ raise
348
+ finally:
349
+ if process is not None and process.poll() is None:
350
+ _terminate_tree(process)
351
+ storage.close()
352
+
353
+
354
+ def _spawn(
355
+ prepared: list[str],
356
+ cwd: Path,
357
+ environment: Mapping[str, str],
358
+ ) -> subprocess.Popen[bytes]:
359
+ if os.name == "nt":
360
+ creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
361
+ return subprocess.Popen(
362
+ prepared,
363
+ cwd=str(cwd),
364
+ env=dict(environment),
365
+ creationflags=creation_flags,
366
+ )
367
+ return subprocess.Popen(
368
+ prepared,
369
+ cwd=str(cwd),
370
+ env=dict(environment),
371
+ start_new_session=True,
372
+ )
373
+
374
+
375
+ def _wait_for_process(process: subprocess.Popen[bytes]) -> tuple[int, bool]:
376
+ """Wait for the child, forwarding an interactive interruption to its tree."""
377
+
378
+ previous_handlers: dict[int, object] = {}
379
+
380
+ def interrupt(_signum: int, _frame: object) -> None:
381
+ raise KeyboardInterrupt
382
+
383
+ for signum in (signal.SIGINT, getattr(signal, "SIGTERM", None)):
384
+ if signum is None:
385
+ continue
386
+ try:
387
+ previous_handlers[signum] = signal.getsignal(signum)
388
+ signal.signal(signum, interrupt)
389
+ except (OSError, ValueError):
390
+ continue
391
+
392
+ try:
393
+ return process.wait(), False
394
+ except KeyboardInterrupt:
395
+ _terminate_tree(process)
396
+ try:
397
+ return process.wait(timeout=10), True
398
+ except subprocess.TimeoutExpired:
399
+ process.kill()
400
+ return process.wait(), True
401
+ finally:
402
+ for signum, handler in previous_handlers.items():
403
+ with contextlib.suppress(OSError, ValueError):
404
+ signal.signal(signum, handler) # type: ignore[arg-type]
405
+
406
+
407
+ def _terminate_tree(process: subprocess.Popen[bytes]) -> None:
408
+ children: list[psutil.Process] = []
409
+ try:
410
+ parent = psutil.Process(process.pid)
411
+ children = parent.children(recursive=True)
412
+ except psutil.Error:
413
+ children = []
414
+
415
+ for child in children:
416
+ with contextlib.suppress(psutil.Error):
417
+ child.terminate()
418
+ with contextlib.suppress(OSError):
419
+ process.terminate()
420
+
421
+ _gone, alive = psutil.wait_procs(children, timeout=3)
422
+ for child in alive:
423
+ with contextlib.suppress(psutil.Error):
424
+ child.kill()
425
+ try:
426
+ process.wait(timeout=3)
427
+ except subprocess.TimeoutExpired:
428
+ process.kill()
429
+ with contextlib.suppress(subprocess.TimeoutExpired):
430
+ process.wait(timeout=3)
431
+
432
+
433
+ def _stop_collector(collector: Collector) -> CollectorStatus:
434
+ try:
435
+ return collector.stop()
436
+ except Exception as exc:
437
+ return CollectorStatus(
438
+ name=collector.name,
439
+ state=CollectorState.FAILED,
440
+ message=f"collector shutdown failed: {exc}",
441
+ stopped_at=utc_now(),
442
+ )
443
+
444
+
445
+ def _close_writer(writer: EventWriter) -> tuple[EventWriterStats | None, str | None]:
446
+ try:
447
+ return writer.close(), None
448
+ except StorageError as exc:
449
+ return None, str(exc)
450
+
451
+
452
+ def _finalize(
453
+ storage: Storage,
454
+ session_id: int,
455
+ *,
456
+ status: SessionStatus,
457
+ ended_at: datetime,
458
+ duration_s: float,
459
+ exit_code: int,
460
+ interrupted: bool,
461
+ error: str,
462
+ statuses: list[CollectorStatus],
463
+ active: list[Collector],
464
+ writer: EventWriter | None,
465
+ event_limit: int,
466
+ ) -> None:
467
+ """Finalize a session after a launch or internal failure."""
468
+
469
+ for collector in reversed(active):
470
+ statuses.append(_stop_collector(collector))
471
+ if writer is not None:
472
+ stats, writer_error = _close_writer(writer)
473
+ else:
474
+ stats, writer_error = None, None
475
+ if writer_error:
476
+ statuses.append(
477
+ CollectorStatus(
478
+ name="event-writer",
479
+ state=CollectorState.FAILED,
480
+ message=writer_error,
481
+ )
482
+ )
483
+ if stats is not None and stats.truncated:
484
+ statuses.append(
485
+ CollectorStatus(
486
+ name="event-writer",
487
+ state=CollectorState.DEGRADED,
488
+ message=(
489
+ f"event limit of {event_limit} reached; {stats.dropped} events were not stored"
490
+ ),
491
+ )
492
+ )
493
+ for name in ("processes", "network"):
494
+ if name not in {collector.name for collector in statuses}:
495
+ statuses.append(
496
+ CollectorStatus(
497
+ name=name,
498
+ state=CollectorState.FAILED,
499
+ message="not started: command could not be launched",
500
+ )
501
+ )
502
+ storage.finish_session(
503
+ session_id,
504
+ status=status,
505
+ ended_at=ended_at,
506
+ duration_s=duration_s,
507
+ exit_code=exit_code,
508
+ interrupted=interrupted,
509
+ error=error,
510
+ )
511
+ storage.set_collector_statuses(session_id, statuses)
512
+
513
+
514
+ __all__ = ["RunOptions", "SessionSummary", "execute"]
@@ -0,0 +1,113 @@
1
+ """Typed event and collector-status models shared by every layer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from enum import StrEnum
8
+ from typing import TypeAlias
9
+
10
+ JSONValue: TypeAlias = str | int | float | bool | list["JSONValue"] | dict[str, "JSONValue"] | None
11
+
12
+
13
+ class EventKind(StrEnum):
14
+ """Stable event identifiers persisted in SQLite and exported in reports."""
15
+
16
+ FILESYSTEM_CREATED = "filesystem.created"
17
+ FILESYSTEM_MODIFIED = "filesystem.modified"
18
+ FILESYSTEM_DELETED = "filesystem.deleted"
19
+ PROCESS_STARTED = "process.started"
20
+ PROCESS_FINISHED = "process.finished"
21
+ PROCESS_STILL_RUNNING = "process.still_running"
22
+ NETWORK_CONNECTION = "network.connection"
23
+ SESSION_WARNING = "session.warning"
24
+
25
+
26
+ class CollectorState(StrEnum):
27
+ """Outcome of a collector for one session."""
28
+
29
+ OK = "ok"
30
+ DEGRADED = "degraded"
31
+ FAILED = "failed"
32
+ DISABLED = "disabled"
33
+
34
+
35
+ class SessionStatus(StrEnum):
36
+ """Lifecycle state of a monitored execution."""
37
+
38
+ RUNNING = "running"
39
+ COMPLETED = "completed"
40
+ FAILED = "failed"
41
+ INTERRUPTED = "interrupted"
42
+
43
+
44
+ def utc_now() -> datetime:
45
+ """Return an aware UTC timestamp."""
46
+
47
+ return datetime.now(UTC)
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Event:
52
+ """An observation emitted by a collector (before it is stored)."""
53
+
54
+ kind: EventKind
55
+ occurred_at: datetime
56
+ source: str
57
+ payload: dict[str, JSONValue]
58
+
59
+ @classmethod
60
+ def create(
61
+ cls,
62
+ kind: EventKind,
63
+ *,
64
+ source: str,
65
+ payload: dict[str, JSONValue] | None = None,
66
+ occurred_at: datetime | None = None,
67
+ ) -> Event:
68
+ """Build an event with a UTC timestamp and a plain JSON payload."""
69
+
70
+ return cls(
71
+ kind=kind,
72
+ occurred_at=occurred_at or utc_now(),
73
+ source=source,
74
+ payload=payload or {},
75
+ )
76
+
77
+
78
+ @dataclass(frozen=True, slots=True)
79
+ class StoredEvent:
80
+ """An event loaded from the local database."""
81
+
82
+ id: int
83
+ session_id: int
84
+ kind: EventKind
85
+ occurred_at: datetime
86
+ source: str
87
+ payload: dict[str, JSONValue]
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class CollectorStatus:
92
+ """Reported coverage and health for a single collector."""
93
+
94
+ name: str
95
+ state: CollectorState
96
+ message: str | None = None
97
+ details: dict[str, JSONValue] = field(default_factory=dict)
98
+ started_at: datetime | None = None
99
+ stopped_at: datetime | None = None
100
+
101
+ @property
102
+ def healthy(self) -> bool:
103
+ return self.state in (CollectorState.OK, CollectorState.DISABLED)
104
+
105
+ def to_dict(self) -> dict[str, JSONValue]:
106
+ return {
107
+ "name": self.name,
108
+ "state": str(self.state),
109
+ "message": self.message,
110
+ "details": self.details,
111
+ "started_at": self.started_at.isoformat() if self.started_at else None,
112
+ "stopped_at": self.stopped_at.isoformat() if self.stopped_at else None,
113
+ }
@@ -0,0 +1,56 @@
1
+ """Ignore rules for filesystem observation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import fnmatch
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ DEFAULT_IGNORE_PATTERNS: tuple[str, ...] = (
10
+ ".git",
11
+ ".hg",
12
+ ".svn",
13
+ "__pycache__",
14
+ ".pytest_cache",
15
+ ".mypy_cache",
16
+ ".ruff_cache",
17
+ ".DS_Store",
18
+ "Thumbs.db",
19
+ "*.pyc",
20
+ "*.pyo",
21
+ )
22
+
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class IgnoreRules:
26
+ """Glob-based exclusions evaluated against a path and its root.
27
+
28
+ A pattern without a slash matches a path component anywhere in the tree.
29
+ A pattern with a slash is matched against the POSIX-style relative path.
30
+ ``exclude_paths`` removes exact paths owned by CmdScope (for example its
31
+ own SQLite database and WAL files) even when the watch root contains them.
32
+ """
33
+
34
+ patterns: tuple[str, ...] = DEFAULT_IGNORE_PATTERNS
35
+ exclude_paths: tuple[Path, ...] = ()
36
+
37
+ def matches(self, path: Path, root: Path) -> bool:
38
+ if path == root:
39
+ return False
40
+ for excluded in self.exclude_paths:
41
+ if path == excluded:
42
+ return True
43
+ try:
44
+ relative = path.relative_to(root).as_posix()
45
+ except ValueError:
46
+ relative = path.name
47
+ parts = relative.split("/")
48
+ for raw_pattern in self.patterns:
49
+ pattern = raw_pattern.strip().strip("/")
50
+ if not pattern:
51
+ continue
52
+ if "/" not in pattern and pattern in parts:
53
+ return True
54
+ if fnmatch.fnmatch(relative, pattern) or fnmatch.fnmatch(path.name, pattern):
55
+ return True
56
+ return False