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,370 @@
1
+ """Filesystem collector: snapshot diff plus native events with polling fallback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import threading
8
+ from collections.abc import Callable
9
+ from dataclasses import dataclass
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from cmdscope.core.collector import CollectorContext
15
+ from cmdscope.core.events import (
16
+ CollectorState,
17
+ CollectorStatus,
18
+ Event,
19
+ EventKind,
20
+ utc_now,
21
+ )
22
+ from cmdscope.core.ignore import IgnoreRules
23
+
24
+
25
+ @dataclass(frozen=True, slots=True)
26
+ class FileState:
27
+ size: int
28
+ mtime_ns: int
29
+ is_dir: bool
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class ScanResult:
34
+ states: dict[Path, FileState]
35
+ truncated: bool
36
+ error_count: int
37
+ error_sample: str | None
38
+
39
+
40
+ def _state(path: Path) -> FileState:
41
+ info = path.lstat()
42
+ return FileState(
43
+ size=info.st_size,
44
+ mtime_ns=info.st_mtime_ns,
45
+ is_dir=stat.S_ISDIR(info.st_mode),
46
+ )
47
+
48
+
49
+ def scan_tree(root: Path, ignore: IgnoreRules, max_files: int) -> ScanResult:
50
+ """Walk *root* without following symlinks and return metadata per entry."""
51
+
52
+ states: dict[Path, FileState] = {}
53
+ stack = [root]
54
+ error_count = 0
55
+ error_sample: str | None = None
56
+ while stack:
57
+ current = stack.pop()
58
+ try:
59
+ with os.scandir(current) as entries:
60
+ for entry in entries:
61
+ path = Path(entry.path)
62
+ if ignore.matches(path, root):
63
+ continue
64
+ try:
65
+ info = entry.stat(follow_symlinks=False)
66
+ except OSError as exc:
67
+ error_count += 1
68
+ error_sample = error_sample or f"{entry.path}: {exc}"
69
+ continue
70
+ is_dir = stat.S_ISDIR(info.st_mode)
71
+ states[path] = FileState(info.st_size, info.st_mtime_ns, is_dir)
72
+ if len(states) >= max_files:
73
+ return ScanResult(states, True, error_count, error_sample)
74
+ if is_dir:
75
+ stack.append(path)
76
+ except OSError as exc:
77
+ error_count += 1
78
+ error_sample = error_sample or f"{current}: {exc}"
79
+ return ScanResult(states, False, error_count, error_sample)
80
+
81
+
82
+ class FilesystemCollector:
83
+ """Observe metadata changes under explicit roots.
84
+
85
+ The baseline snapshot is taken before the monitored command starts. Native
86
+ events provide low latency; a final snapshot reconciliation catches changes
87
+ that the OS event stream missed. If the snapshot limit is reached, CmdScope
88
+ refuses to infer deletions from the partial snapshot and reports the
89
+ collector as degraded.
90
+ """
91
+
92
+ name = "filesystem"
93
+
94
+ def __init__(
95
+ self,
96
+ *,
97
+ max_files: int,
98
+ poll_interval: float,
99
+ use_native_events: bool = True,
100
+ clock: Callable[[], datetime] = utc_now,
101
+ ) -> None:
102
+ self._max_files = max_files
103
+ self._poll_interval = poll_interval
104
+ self._use_native_events = use_native_events
105
+ self._clock = clock
106
+ self._lock = threading.RLock()
107
+ self._stop = threading.Event()
108
+ self._states: dict[Path, FileState] = {}
109
+ self._roots: tuple[Path, ...] = ()
110
+ self._ignore = IgnoreRules()
111
+ self._emit: Callable[[Event], None] | None = None
112
+ self._events_emitted = 0
113
+ self._baseline_truncated = False
114
+ self._scan_errors = 0
115
+ self._scan_error_sample: str | None = None
116
+ self._observer: Any = None
117
+ self._thread: threading.Thread | None = None
118
+ self._backend = "none"
119
+ self._observer_error: str | None = None
120
+ self._fatal_error: str | None = None
121
+ self._started_at: datetime | None = None
122
+
123
+ def start(self, context: CollectorContext) -> None:
124
+ self._roots = context.watch_roots
125
+ self._ignore = context.ignore
126
+ self._emit = context.emit
127
+ self._started_at = utc_now()
128
+
129
+ baseline: dict[Path, FileState] = {}
130
+ for root in self._roots:
131
+ scan = scan_tree(root, self._ignore, self._max_files)
132
+ baseline.update(scan.states)
133
+ self._baseline_truncated = self._baseline_truncated or scan.truncated
134
+ self._accumulate_scan_errors(scan)
135
+ with self._lock:
136
+ self._states = baseline
137
+
138
+ if self._use_native_events and self._start_observer():
139
+ self._backend = "native"
140
+ else:
141
+ self._backend = "polling"
142
+ self._thread = threading.Thread(
143
+ target=self._poll_loop, name="cmdscope-fs-poller", daemon=True
144
+ )
145
+ self._thread.start()
146
+
147
+ def _accumulate_scan_errors(self, scan: ScanResult) -> None:
148
+ self._scan_errors += scan.error_count
149
+ if self._scan_error_sample is None:
150
+ self._scan_error_sample = scan.error_sample
151
+
152
+ def _start_observer(self) -> bool:
153
+ try:
154
+ from watchdog.events import FileSystemEventHandler
155
+ from watchdog.observers import Observer
156
+ except Exception as exc: # pragma: no cover - required dependency is installed
157
+ self._observer_error = f"watchdog unavailable: {exc}"
158
+ return False
159
+
160
+ collection = self
161
+
162
+ class Handler(FileSystemEventHandler):
163
+ def on_any_event(self, event: Any) -> None:
164
+ collection._handle_watchdog_event(event)
165
+
166
+ try:
167
+ observer = Observer()
168
+ handler = Handler()
169
+ for root in self._roots:
170
+ observer.schedule(handler, str(root), recursive=True)
171
+ observer.start()
172
+ self._observer = observer
173
+ return True
174
+ except Exception as exc:
175
+ self._observer_error = f"native filesystem observer unavailable: {exc}"
176
+ self._observer = None
177
+ return False
178
+
179
+ def _handle_watchdog_event(self, event: Any) -> None:
180
+ try:
181
+ event_type = str(getattr(event, "event_type", ""))
182
+ is_directory = bool(getattr(event, "is_directory", False))
183
+ if event_type == "modified" and is_directory:
184
+ return
185
+ if event_type == "moved":
186
+ source = getattr(event, "src_path", None)
187
+ destination = getattr(event, "dest_path", None)
188
+ paths = [source, destination]
189
+ elif event_type in {"created", "modified", "deleted"}:
190
+ paths = [getattr(event, "src_path", None)]
191
+ else:
192
+ return
193
+ for value in paths:
194
+ if value:
195
+ self._observe_path(Path(value))
196
+ except Exception as exc: # pragma: no cover - watchdog callbacks must keep running
197
+ self._fatal_error = f"filesystem event handling failed: {exc}"
198
+
199
+ def _observe_path(self, path: Path) -> None:
200
+ root = self._root_for(path)
201
+ if root is None or self._ignore.matches(path, root):
202
+ return
203
+ try:
204
+ state = _state(path)
205
+ except FileNotFoundError:
206
+ self._observe_deleted(path, root)
207
+ return
208
+ except OSError:
209
+ self._scan_errors += 1
210
+ return
211
+
212
+ event: Event | None = None
213
+ with self._lock:
214
+ previous = self._states.get(path)
215
+ if previous is None:
216
+ self._states[path] = state
217
+ event = self._event(EventKind.FILESYSTEM_CREATED, path, state, root)
218
+ elif previous != state:
219
+ self._states[path] = state
220
+ if not (previous.is_dir and state.is_dir):
221
+ event = self._event(EventKind.FILESYSTEM_MODIFIED, path, state, root)
222
+ if event is not None:
223
+ self._emit_event(event)
224
+
225
+ def _observe_deleted(self, path: Path, root: Path) -> None:
226
+ with self._lock:
227
+ previous = self._states.pop(path, None)
228
+ if previous is not None:
229
+ self._emit_event(self._event(EventKind.FILESYSTEM_DELETED, path, previous, root))
230
+
231
+ def _root_for(self, path: Path) -> Path | None:
232
+ matches = [root for root in self._roots if path == root or path.is_relative_to(root)]
233
+ return max(matches, key=lambda item: len(item.parts)) if matches else None
234
+
235
+ def _event(self, kind: EventKind, path: Path, state: FileState, root: Path) -> Event:
236
+ return Event.create(
237
+ kind,
238
+ source=self.name,
239
+ occurred_at=self._clock(),
240
+ payload={
241
+ "path": str(path),
242
+ "root": str(root),
243
+ "is_dir": state.is_dir,
244
+ "size": state.size,
245
+ "mtime_ns": state.mtime_ns,
246
+ },
247
+ )
248
+
249
+ def _emit_event(self, event: Event) -> None:
250
+ if self._emit is None:
251
+ return
252
+ try:
253
+ self._emit(event)
254
+ self._events_emitted += 1
255
+ except Exception as exc: # pragma: no cover - defensive boundary around the queue
256
+ self._fatal_error = f"event emission failed: {exc}"
257
+ self._stop.set()
258
+
259
+ def _poll_loop(self) -> None:
260
+ while not self._stop.is_set():
261
+ try:
262
+ self._reconcile()
263
+ except Exception as exc: # pragma: no cover - defensive thread boundary
264
+ self._fatal_error = f"filesystem polling failed: {exc}"
265
+ return
266
+ if self._stop.wait(self._poll_interval):
267
+ return
268
+
269
+ def _reconcile(self) -> None:
270
+ if self._baseline_truncated:
271
+ return
272
+ merged: dict[Path, FileState] = {}
273
+ truncated = False
274
+ for watch_root in self._roots:
275
+ scan = scan_tree(watch_root, self._ignore, self._max_files)
276
+ merged.update(scan.states)
277
+ truncated = truncated or scan.truncated
278
+ self._accumulate_scan_errors(scan)
279
+ if truncated:
280
+ self._baseline_truncated = True
281
+ return
282
+
283
+ added: list[tuple[Path, FileState]] = []
284
+ removed: list[tuple[Path, FileState]] = []
285
+ changed: list[tuple[Path, FileState]] = []
286
+ with self._lock:
287
+ previous_states = self._states
288
+ for path, state in merged.items():
289
+ previous = previous_states.get(path)
290
+ if previous is None:
291
+ added.append((path, state))
292
+ elif previous != state and not (previous.is_dir and state.is_dir):
293
+ changed.append((path, state))
294
+ for path, state in previous_states.items():
295
+ if path not in merged:
296
+ removed.append((path, state))
297
+ self._states = merged
298
+
299
+ for path, state in sorted(added, key=lambda item: str(item[0])):
300
+ root = self._root_for(path)
301
+ if root is not None:
302
+ self._emit_event(self._event(EventKind.FILESYSTEM_CREATED, path, state, root))
303
+ for path, state in sorted(removed, key=lambda item: str(item[0])):
304
+ root = self._root_for(path)
305
+ if root is not None:
306
+ self._emit_event(self._event(EventKind.FILESYSTEM_DELETED, path, state, root))
307
+ for path, state in sorted(changed, key=lambda item: str(item[0])):
308
+ root = self._root_for(path)
309
+ if root is not None:
310
+ self._emit_event(self._event(EventKind.FILESYSTEM_MODIFIED, path, state, root))
311
+
312
+ def stop(self) -> CollectorStatus:
313
+ self._stop.set()
314
+ if self._observer is not None:
315
+ try:
316
+ self._observer.stop()
317
+ self._observer.join(timeout=10)
318
+ except Exception as exc: # pragma: no cover - observer shutdown boundary
319
+ self._fatal_error = f"native observer shutdown failed: {exc}"
320
+ self._observer = None
321
+ if self._thread is not None:
322
+ self._thread.join(timeout=30)
323
+ self._thread = None
324
+
325
+ if not self._baseline_truncated:
326
+ try:
327
+ self._reconcile()
328
+ except Exception as exc:
329
+ self._fatal_error = f"final filesystem reconciliation failed: {exc}"
330
+
331
+ state = CollectorState.OK
332
+ messages: list[str] = []
333
+ if self._fatal_error:
334
+ state = CollectorState.FAILED
335
+ messages.append(self._fatal_error)
336
+ elif self._baseline_truncated:
337
+ state = CollectorState.DEGRADED
338
+ messages.append(
339
+ f"snapshot limit of {self._max_files} entries reached; changes are partial"
340
+ )
341
+ elif self._scan_errors:
342
+ state = CollectorState.DEGRADED
343
+ messages.append(
344
+ f"{self._scan_errors} paths could not be inspected"
345
+ + (f" (first: {self._scan_error_sample})" if self._scan_error_sample else "")
346
+ )
347
+ if self._observer_error:
348
+ if state == CollectorState.OK:
349
+ state = CollectorState.DEGRADED
350
+ messages.append(self._observer_error)
351
+
352
+ details: dict[str, Any] = {
353
+ "backend": self._backend,
354
+ "watch_roots": [str(root) for root in self._roots],
355
+ "max_files": self._max_files,
356
+ "baseline_truncated": self._baseline_truncated,
357
+ "scan_errors": self._scan_errors,
358
+ "events_emitted": self._events_emitted,
359
+ }
360
+ if self._observer_error:
361
+ details["observer_error"] = self._observer_error
362
+
363
+ return CollectorStatus(
364
+ name=self.name,
365
+ state=state,
366
+ message="; ".join(messages) if messages else None,
367
+ details=details,
368
+ started_at=self._started_at,
369
+ stopped_at=utc_now(),
370
+ )
@@ -0,0 +1,196 @@
1
+ """Network endpoint collector based on psutil polling and the process registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import threading
7
+ from collections.abc import Callable
8
+ from datetime import datetime
9
+ from typing import Any
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
+
23
+
24
+ def _endpoint(value: Any) -> str | None:
25
+ if not value:
26
+ return None
27
+ try:
28
+ host, port = value[0], value[1]
29
+ except (TypeError, IndexError):
30
+ return str(value)
31
+ host_text = str(host)
32
+ if ":" in host_text and not host_text.startswith("["):
33
+ host_text = f"[{host_text}]"
34
+ return f"{host_text}:{port}"
35
+
36
+
37
+ def _family_name(family: Any) -> str:
38
+ if family == socket.AF_INET6:
39
+ return "IPv6"
40
+ if family == socket.AF_INET:
41
+ return "IPv4"
42
+ return str(family)
43
+
44
+
45
+ def _protocol_name(protocol: Any) -> str:
46
+ if protocol == socket.SOCK_STREAM:
47
+ return "TCP"
48
+ if protocol == socket.SOCK_DGRAM:
49
+ return "UDP"
50
+ return str(protocol)
51
+
52
+
53
+ class NetworkCollector:
54
+ """Attribute observed inet sockets to processes already in the session.
55
+
56
+ Sockets are sampled. Connections shorter than one poll interval can be
57
+ missed. No packet contents or DNS lookups are used.
58
+ """
59
+
60
+ name = "network"
61
+
62
+ def __init__(self, *, poll_interval: float) -> None:
63
+ self._poll_interval = poll_interval
64
+ self._stop = threading.Event()
65
+ self._registry = ProcessRegistry()
66
+ self._emit: Callable[[Event], None] | None = None
67
+ self._thread: threading.Thread | None = None
68
+ self._seen: set[tuple[Any, ...]] = set()
69
+ self._access_denied = False
70
+ self._unattributed: set[tuple[Any, ...]] = set()
71
+ self._error: str | None = None
72
+ self._started_at: datetime | None = None
73
+ self._connections = 0
74
+
75
+ def start(self, context: CollectorContext) -> None:
76
+ self._registry = context.registry
77
+ self._emit = context.emit
78
+ self._started_at = utc_now()
79
+ self._thread = threading.Thread(
80
+ target=self._poll_loop, name="cmdscope-network-poller", daemon=True
81
+ )
82
+ self._thread.start()
83
+
84
+ def _poll_loop(self) -> None:
85
+ while not self._stop.is_set():
86
+ try:
87
+ self.poll_once()
88
+ except Exception as exc: # pragma: no cover - defensive thread boundary
89
+ self._error = f"network polling failed: {exc}"
90
+ return
91
+ if self._stop.wait(self._poll_interval):
92
+ return
93
+
94
+ def poll_once(self) -> None:
95
+ """Sample sockets once. Public for deterministic diagnostics/tests."""
96
+
97
+ try:
98
+ connections = psutil.net_connections(kind="inet")
99
+ except (psutil.AccessDenied, PermissionError) as exc:
100
+ self._access_denied = True
101
+ self._error = f"network inspection access denied: {exc}"
102
+ return
103
+ except OSError as exc:
104
+ self._error = f"network inspection failed: {exc}"
105
+ return
106
+
107
+ registry = self._registry.snapshot()
108
+ for connection in connections:
109
+ pid = getattr(connection, "pid", None)
110
+ local = getattr(connection, "laddr", None)
111
+ remote = getattr(connection, "raddr", None)
112
+ family = getattr(connection, "family", None)
113
+ protocol = getattr(connection, "type", None)
114
+ if pid is None:
115
+ self._unattributed.add(
116
+ (
117
+ family,
118
+ protocol,
119
+ tuple(local) if local else None,
120
+ tuple(remote) if remote else None,
121
+ )
122
+ )
123
+ continue
124
+ if pid not in registry:
125
+ continue
126
+ key = (
127
+ pid,
128
+ family,
129
+ protocol,
130
+ tuple(local) if local else None,
131
+ tuple(remote) if remote else None,
132
+ )
133
+ if key in self._seen:
134
+ continue
135
+ self._seen.add(key)
136
+ payload = {
137
+ "pid": pid,
138
+ "process_name": registry[pid],
139
+ "family": _family_name(family),
140
+ "protocol": _protocol_name(protocol),
141
+ "local_address": _endpoint(local),
142
+ "remote_address": _endpoint(remote),
143
+ "status": str(getattr(connection, "status", "") or "UNKNOWN"),
144
+ }
145
+ self._emit_event(
146
+ Event.create(EventKind.NETWORK_CONNECTION, source=self.name, payload=payload)
147
+ )
148
+
149
+ def _emit_event(self, event: Event) -> None:
150
+ if self._emit is None:
151
+ return
152
+ try:
153
+ self._emit(event)
154
+ self._connections += 1
155
+ except Exception as exc: # pragma: no cover - defensive queue boundary
156
+ self._error = f"event emission failed: {exc}"
157
+ self._stop.set()
158
+
159
+ def stop(self) -> CollectorStatus:
160
+ self._stop.set()
161
+ if self._thread is not None:
162
+ self._thread.join(timeout=30)
163
+ self._thread = None
164
+ try:
165
+ self.poll_once()
166
+ except Exception as exc:
167
+ self._error = f"final network poll failed: {exc}"
168
+
169
+ state = CollectorState.OK
170
+ messages: list[str] = []
171
+ if self._error and self._access_denied:
172
+ state = CollectorState.DEGRADED
173
+ messages.append(self._error)
174
+ elif self._error:
175
+ state = CollectorState.FAILED
176
+ messages.append(self._error)
177
+ elif not self._connections and self._unattributed:
178
+ state = CollectorState.DEGRADED
179
+ messages.append(
180
+ "socket owners are not visible on this platform; "
181
+ "network endpoints could not be attributed"
182
+ )
183
+
184
+ return CollectorStatus(
185
+ name=self.name,
186
+ state=state,
187
+ message="; ".join(messages) if messages else None,
188
+ details={
189
+ "connections_observed": self._connections,
190
+ "unattributed_sockets": len(self._unattributed),
191
+ "access_denied": self._access_denied,
192
+ "poll_interval": self._poll_interval,
193
+ },
194
+ started_at=self._started_at,
195
+ stopped_at=utc_now(),
196
+ )