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/__init__.py +12 -0
- cmdscope/__main__.py +8 -0
- cmdscope/analysis/__init__.py +1 -0
- cmdscope/analysis/comparison.py +134 -0
- cmdscope/analysis/summary.py +37 -0
- cmdscope/cli.py +555 -0
- cmdscope/collectors/__init__.py +1 -0
- cmdscope/collectors/filesystem.py +370 -0
- cmdscope/collectors/network.py +196 -0
- cmdscope/collectors/processes.py +365 -0
- cmdscope/config.py +461 -0
- cmdscope/core/__init__.py +1 -0
- cmdscope/core/collector.py +48 -0
- cmdscope/core/engine.py +514 -0
- cmdscope/core/events.py +113 -0
- cmdscope/core/ignore.py +56 -0
- cmdscope/core/registry.py +33 -0
- cmdscope/core/session.py +81 -0
- cmdscope/diagnostics.py +183 -0
- cmdscope/errors.py +36 -0
- cmdscope/platforms/__init__.py +57 -0
- cmdscope/platforms/base.py +140 -0
- cmdscope/platforms/linux.py +28 -0
- cmdscope/platforms/macos.py +26 -0
- cmdscope/platforms/windows.py +28 -0
- cmdscope/privacy.py +143 -0
- cmdscope/py.typed +0 -0
- cmdscope/reports/__init__.py +1 -0
- cmdscope/reports/html.py +279 -0
- cmdscope/reports/json_report.py +133 -0
- cmdscope/reports/terminal.py +429 -0
- cmdscope/storage/__init__.py +6 -0
- cmdscope/storage/sqlite.py +469 -0
- cmdscope/storage/writer.py +105 -0
- cmdscope-0.1.0.dist-info/METADATA +353 -0
- cmdscope-0.1.0.dist-info/RECORD +40 -0
- cmdscope-0.1.0.dist-info/WHEEL +5 -0
- cmdscope-0.1.0.dist-info/entry_points.txt +2 -0
- cmdscope-0.1.0.dist-info/licenses/LICENSE +21 -0
- cmdscope-0.1.0.dist-info/top_level.txt +1 -0
cmdscope/core/engine.py
ADDED
|
@@ -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"]
|
cmdscope/core/events.py
ADDED
|
@@ -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
|
+
}
|
cmdscope/core/ignore.py
ADDED
|
@@ -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
|