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,365 @@
1
+ """Process-tree collector based on bounded psutil polling."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shlex
6
+ import threading
7
+ from collections.abc import Callable
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+
11
+ import psutil
12
+
13
+ from cmdscope.core.collector import CollectorContext
14
+ from cmdscope.core.events import (
15
+ CollectorState,
16
+ CollectorStatus,
17
+ Event,
18
+ EventKind,
19
+ utc_now,
20
+ )
21
+ from cmdscope.core.registry import ProcessRegistry
22
+ from cmdscope.privacy import sanitize_command
23
+
24
+
25
+ @dataclass(slots=True)
26
+ class _ProcessInfo:
27
+ pid: int
28
+ ppid: int | None
29
+ name: str
30
+ exe: str | None
31
+ cmdline: tuple[str, ...]
32
+ create_time: float | None
33
+ first_seen: datetime
34
+
35
+
36
+ class ProcessCollector:
37
+ """Track the root process and its descendants.
38
+
39
+ Discovery is poll-based. A child that starts and exits between two polls can
40
+ be missed; that limitation is part of the collector status. PIDs are matched
41
+ with their creation time to avoid attributing a reused PID to the session.
42
+ """
43
+
44
+ name = "processes"
45
+
46
+ def __init__(self, *, poll_interval: float) -> None:
47
+ self._poll_interval = poll_interval
48
+ self._stop = threading.Event()
49
+ self._lock = threading.RLock()
50
+ self._known: dict[int, _ProcessInfo] = {}
51
+ self._registry = ProcessRegistry()
52
+ self._emit: Callable[[Event], None] | None = None
53
+ self._root_pid = 0
54
+ self._command_name = ""
55
+ self._thread: threading.Thread | None = None
56
+ self._access_denied = False
57
+ self._error: str | None = None
58
+ self._root_vanished = False
59
+ self._started_at: datetime | None = None
60
+ self._events_emitted = 0
61
+
62
+ def set_registry(self, registry: ProcessRegistry) -> None:
63
+ """Use the session-wide registry shared with the network collector."""
64
+
65
+ self._registry = registry
66
+
67
+ def start(self, context: CollectorContext) -> None:
68
+ self._root_pid = context.root_pid
69
+ self._command_name = context.command_name
70
+ self._registry = context.registry
71
+ self._emit = context.emit
72
+ self._started_at = utc_now()
73
+
74
+ try:
75
+ process = psutil.Process(self._root_pid)
76
+ self._register(process)
77
+ except psutil.NoSuchProcess:
78
+ self._root_vanished = True
79
+ self._emit_started(
80
+ pid=self._root_pid,
81
+ ppid=None,
82
+ name=self._command_name,
83
+ exe=None,
84
+ cmdline=(),
85
+ create_time=None,
86
+ )
87
+ self._emit_finished(
88
+ pid=self._root_pid,
89
+ name=self._command_name,
90
+ exe=None,
91
+ cmdline=(),
92
+ reason="exited_before_observation",
93
+ first_seen=self._started_at,
94
+ )
95
+ except psutil.AccessDenied as exc:
96
+ self._access_denied = True
97
+ self._error = f"cannot inspect root process: {exc}"
98
+
99
+ self._thread = threading.Thread(
100
+ target=self._poll_loop, name="cmdscope-process-poller", daemon=True
101
+ )
102
+ self._thread.start()
103
+
104
+ def _poll_loop(self) -> None:
105
+ while not self._stop.is_set():
106
+ try:
107
+ self._poll()
108
+ except Exception as exc: # pragma: no cover - defensive thread boundary
109
+ self._error = f"process polling failed: {exc}"
110
+ return
111
+ if self._stop.wait(self._poll_interval):
112
+ return
113
+
114
+ def _poll(self) -> None:
115
+ self._discover_descendants()
116
+ self._reap_gone()
117
+
118
+ def _discover_descendants(self) -> None:
119
+ with self._lock:
120
+ known_pids = list(self._known)
121
+ for pid in known_pids:
122
+ try:
123
+ process = psutil.Process(pid)
124
+ if not self._still_same_process(process, pid):
125
+ continue
126
+ children = process.children(recursive=False)
127
+ except psutil.NoSuchProcess:
128
+ continue
129
+ except psutil.AccessDenied:
130
+ self._access_denied = True
131
+ continue
132
+ for child in children:
133
+ self._register(child)
134
+
135
+ try:
136
+ root = psutil.Process(self._root_pid)
137
+ for child in root.children(recursive=True):
138
+ self._register(child)
139
+ except psutil.NoSuchProcess:
140
+ return
141
+ except psutil.AccessDenied:
142
+ self._access_denied = True
143
+
144
+ def _register(self, process: psutil.Process) -> None:
145
+ try:
146
+ pid = process.pid
147
+ except psutil.NoSuchProcess:
148
+ return
149
+ with self._lock:
150
+ if pid in self._known:
151
+ return
152
+
153
+ ppid: int | None = None
154
+ name = f"pid-{pid}"
155
+ exe: str | None = None
156
+ cmdline_value: list[str] = []
157
+ create_time: float | None = None
158
+ try:
159
+ ppid = process.ppid()
160
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
161
+ ppid = None
162
+ try:
163
+ name = process.name() or name
164
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
165
+ name = f"pid-{pid}"
166
+ try:
167
+ exe = process.exe()
168
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
169
+ exe = None
170
+ try:
171
+ cmdline_value = process.cmdline()
172
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
173
+ cmdline_value = []
174
+ try:
175
+ create_time = process.create_time()
176
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
177
+ create_time = None
178
+
179
+ cmdline = sanitize_command(cmdline_value)
180
+ info = _ProcessInfo(
181
+ pid=pid,
182
+ ppid=ppid,
183
+ name=name,
184
+ exe=exe,
185
+ cmdline=cmdline,
186
+ create_time=create_time,
187
+ first_seen=utc_now(),
188
+ )
189
+ with self._lock:
190
+ if pid in self._known:
191
+ return
192
+ self._known[pid] = info
193
+ self._registry.add(pid, name)
194
+ self._emit_started(
195
+ pid=pid,
196
+ ppid=ppid,
197
+ name=name,
198
+ exe=exe,
199
+ cmdline=cmdline,
200
+ create_time=create_time,
201
+ )
202
+
203
+ def _still_same_process(self, process: psutil.Process, pid: int) -> bool:
204
+ with self._lock:
205
+ info = self._known.get(pid)
206
+ if info is None:
207
+ return False
208
+ try:
209
+ return abs(process.create_time() - (info.create_time or 0.0)) < 0.01
210
+ except psutil.NoSuchProcess:
211
+ return False
212
+ except psutil.AccessDenied:
213
+ self._access_denied = True
214
+ return True
215
+
216
+ def _reap_gone(self) -> None:
217
+ with self._lock:
218
+ pids = list(self._known)
219
+ for pid in pids:
220
+ with self._lock:
221
+ info = self._known.get(pid)
222
+ if info is None:
223
+ continue
224
+ try:
225
+ process = psutil.Process(pid)
226
+ if process.is_running() and self._still_same_process(process, pid):
227
+ continue
228
+ except psutil.NoSuchProcess:
229
+ pass
230
+ except psutil.AccessDenied:
231
+ self._access_denied = True
232
+ continue
233
+ self._mark_finished(pid, info, reason="no_longer_observed")
234
+
235
+ def _mark_finished(self, pid: int, info: _ProcessInfo, *, reason: str) -> None:
236
+ with self._lock:
237
+ current = self._known.pop(pid, None)
238
+ if current is None:
239
+ return
240
+ self._registry.remove(pid)
241
+ self._emit_finished(
242
+ pid=pid,
243
+ name=info.name,
244
+ exe=info.exe,
245
+ cmdline=info.cmdline,
246
+ reason=reason,
247
+ first_seen=info.first_seen,
248
+ )
249
+
250
+ def _emit_started(
251
+ self,
252
+ *,
253
+ pid: int,
254
+ ppid: int | None,
255
+ name: str,
256
+ exe: str | None,
257
+ cmdline: tuple[str, ...],
258
+ create_time: float | None,
259
+ ) -> None:
260
+ self._emit_event(
261
+ Event.create(
262
+ EventKind.PROCESS_STARTED,
263
+ source=self.name,
264
+ payload={
265
+ "pid": pid,
266
+ "ppid": ppid,
267
+ "name": name,
268
+ "exe": exe,
269
+ "cmdline": list(cmdline),
270
+ "command": shlex.join(cmdline),
271
+ "create_time": create_time,
272
+ },
273
+ )
274
+ )
275
+
276
+ def _emit_finished(
277
+ self,
278
+ *,
279
+ pid: int,
280
+ name: str,
281
+ exe: str | None,
282
+ cmdline: tuple[str, ...],
283
+ reason: str,
284
+ first_seen: datetime,
285
+ ) -> None:
286
+ now = utc_now()
287
+ self._emit_event(
288
+ Event.create(
289
+ EventKind.PROCESS_FINISHED,
290
+ source=self.name,
291
+ occurred_at=now,
292
+ payload={
293
+ "pid": pid,
294
+ "name": name,
295
+ "exe": exe,
296
+ "cmdline": list(cmdline),
297
+ "command": shlex.join(cmdline),
298
+ "reason": reason,
299
+ "observed_lifetime_s": max(0.0, (now - first_seen).total_seconds()),
300
+ },
301
+ )
302
+ )
303
+
304
+ def _emit_event(self, event: Event) -> None:
305
+ if self._emit is None:
306
+ return
307
+ try:
308
+ self._emit(event)
309
+ self._events_emitted += 1
310
+ except Exception as exc: # pragma: no cover - defensive queue boundary
311
+ self._error = f"event emission failed: {exc}"
312
+ self._stop.set()
313
+
314
+ def stop(self) -> CollectorStatus:
315
+ self._stop.set()
316
+ if self._thread is not None:
317
+ self._thread.join(timeout=30)
318
+ self._thread = None
319
+ try:
320
+ self._poll()
321
+ except Exception as exc:
322
+ self._error = f"final process poll failed: {exc}"
323
+
324
+ with self._lock:
325
+ survivors = list(self._known.values())
326
+ for info in survivors:
327
+ self._emit_event(
328
+ Event.create(
329
+ EventKind.PROCESS_STILL_RUNNING,
330
+ source=self.name,
331
+ payload={
332
+ "pid": info.pid,
333
+ "ppid": info.ppid,
334
+ "name": info.name,
335
+ "exe": info.exe,
336
+ "cmdline": list(info.cmdline),
337
+ "command": shlex.join(info.cmdline),
338
+ },
339
+ )
340
+ )
341
+
342
+ state = CollectorState.OK
343
+ messages: list[str] = []
344
+ if self._error:
345
+ state = CollectorState.FAILED
346
+ messages.append(self._error)
347
+ elif self._access_denied:
348
+ state = CollectorState.DEGRADED
349
+ messages.append("some processes could not be inspected (access denied)")
350
+ if self._root_vanished:
351
+ messages.append("root process exited before process observation began")
352
+
353
+ return CollectorStatus(
354
+ name=self.name,
355
+ state=state,
356
+ message="; ".join(messages) if messages else None,
357
+ details={
358
+ "root_pid": self._root_pid,
359
+ "observed_processes": self._events_emitted,
360
+ "still_running": len(survivors),
361
+ "access_denied": self._access_denied,
362
+ },
363
+ started_at=self._started_at,
364
+ stopped_at=utc_now(),
365
+ )