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/config.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Configuration loading, validation, and path resolution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import tomllib
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from cmdscope.core.ignore import DEFAULT_IGNORE_PATTERNS
|
|
13
|
+
from cmdscope.errors import ConfigurationError
|
|
14
|
+
from cmdscope.platforms import current_platform_info
|
|
15
|
+
|
|
16
|
+
_ALLOWED_TOP_LEVEL = {"filesystem", "processes", "network", "privacy", "storage", "report"}
|
|
17
|
+
_ALLOWED_KEYS = {
|
|
18
|
+
"filesystem": {"enabled", "watch", "ignore", "max_files", "poll_interval", "native_events"},
|
|
19
|
+
"processes": {"enabled", "poll_interval"},
|
|
20
|
+
"network": {"enabled", "poll_interval"},
|
|
21
|
+
"privacy": {"capture_environment", "show_environment"},
|
|
22
|
+
"storage": {"database", "max_sessions", "max_events"},
|
|
23
|
+
"report": {"max_events", "format"},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class FilesystemConfig:
|
|
29
|
+
enabled: bool = True
|
|
30
|
+
watch: tuple[str, ...] = ()
|
|
31
|
+
ignore: tuple[str, ...] = DEFAULT_IGNORE_PATTERNS
|
|
32
|
+
max_files: int = 100_000
|
|
33
|
+
poll_interval: float = 1.0
|
|
34
|
+
native_events: bool = True
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True, slots=True)
|
|
38
|
+
class ProcessesConfig:
|
|
39
|
+
enabled: bool = True
|
|
40
|
+
poll_interval: float = 0.5
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class NetworkConfig:
|
|
45
|
+
enabled: bool = True
|
|
46
|
+
poll_interval: float = 0.25
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class PrivacyConfig:
|
|
51
|
+
capture_environment: bool = True
|
|
52
|
+
show_environment: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True, slots=True)
|
|
56
|
+
class StorageConfig:
|
|
57
|
+
database: Path | None = None
|
|
58
|
+
max_sessions: int = 0
|
|
59
|
+
max_events: int = 100_000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True, slots=True)
|
|
63
|
+
class ReportConfig:
|
|
64
|
+
max_events: int = 500
|
|
65
|
+
default_format: str = "terminal"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class Config:
|
|
70
|
+
filesystem: FilesystemConfig = field(default_factory=FilesystemConfig)
|
|
71
|
+
processes: ProcessesConfig = field(default_factory=ProcessesConfig)
|
|
72
|
+
network: NetworkConfig = field(default_factory=NetworkConfig)
|
|
73
|
+
privacy: PrivacyConfig = field(default_factory=PrivacyConfig)
|
|
74
|
+
storage: StorageConfig = field(default_factory=StorageConfig)
|
|
75
|
+
report: ReportConfig = field(default_factory=ReportConfig)
|
|
76
|
+
source_path: Path | None = None
|
|
77
|
+
base_dir: Path = field(default_factory=Path.cwd)
|
|
78
|
+
warnings: tuple[str, ...] = ()
|
|
79
|
+
|
|
80
|
+
def database_path(self) -> Path:
|
|
81
|
+
"""Return the configured database path or the platform default."""
|
|
82
|
+
|
|
83
|
+
if self.storage.database is not None:
|
|
84
|
+
return self.storage.database
|
|
85
|
+
return current_platform_info().default_database_path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def user_config_path(environment: Mapping[str, str] | None = None) -> Path:
|
|
89
|
+
env = os.environ if environment is None else environment
|
|
90
|
+
info = current_platform_info(env)
|
|
91
|
+
return info.default_config_dir / "config.toml"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def find_config_path(
|
|
95
|
+
explicit: Path | None,
|
|
96
|
+
*,
|
|
97
|
+
cwd: Path,
|
|
98
|
+
environment: Mapping[str, str] | None = None,
|
|
99
|
+
) -> Path | None:
|
|
100
|
+
"""Resolve configuration precedence: explicit, env, local, user."""
|
|
101
|
+
|
|
102
|
+
env = os.environ if environment is None else environment
|
|
103
|
+
if explicit is not None:
|
|
104
|
+
candidate = explicit.expanduser()
|
|
105
|
+
if not candidate.is_absolute():
|
|
106
|
+
candidate = cwd / candidate
|
|
107
|
+
if not candidate.is_file():
|
|
108
|
+
raise ConfigurationError(f"configuration file not found: {candidate}")
|
|
109
|
+
return candidate.resolve()
|
|
110
|
+
|
|
111
|
+
env_path = env.get("CMDSCOPE_CONFIG")
|
|
112
|
+
if env_path:
|
|
113
|
+
candidate = Path(env_path).expanduser()
|
|
114
|
+
if not candidate.is_absolute():
|
|
115
|
+
candidate = cwd / candidate
|
|
116
|
+
if not candidate.is_file():
|
|
117
|
+
raise ConfigurationError(f"CMDSCOPE_CONFIG points to a missing file: {candidate}")
|
|
118
|
+
return candidate.resolve()
|
|
119
|
+
|
|
120
|
+
local = cwd / "cmdscope.toml"
|
|
121
|
+
if local.is_file():
|
|
122
|
+
return local.resolve()
|
|
123
|
+
|
|
124
|
+
user = user_config_path(env)
|
|
125
|
+
if user.is_file():
|
|
126
|
+
return user.resolve()
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _require_table(value: Any, name: str) -> Mapping[str, Any]:
|
|
131
|
+
if not isinstance(value, dict):
|
|
132
|
+
raise ConfigurationError(f"configuration section [{name}] must be a table")
|
|
133
|
+
return value
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _unknown_keys(section: str, table: Mapping[str, Any], warnings: list[str]) -> None:
|
|
137
|
+
for key in sorted(set(table) - _ALLOWED_KEYS[section]):
|
|
138
|
+
warnings.append(f"unknown configuration key ignored: {section}.{key}")
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _bool(table: Mapping[str, Any], key: str, default: bool, section: str) -> bool:
|
|
142
|
+
value = table.get(key, default)
|
|
143
|
+
if not isinstance(value, bool):
|
|
144
|
+
raise ConfigurationError(f"{section}.{key} must be true or false")
|
|
145
|
+
return value
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _int(table: Mapping[str, Any], key: str, default: int, section: str) -> int:
|
|
149
|
+
value = table.get(key, default)
|
|
150
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
151
|
+
raise ConfigurationError(f"{section}.{key} must be an integer")
|
|
152
|
+
return value
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _float(table: Mapping[str, Any], key: str, default: float, section: str) -> float:
|
|
156
|
+
value = table.get(key, default)
|
|
157
|
+
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
|
158
|
+
raise ConfigurationError(f"{section}.{key} must be a number")
|
|
159
|
+
return float(value)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _str_list(
|
|
163
|
+
table: Mapping[str, Any], key: str, default: tuple[str, ...], section: str
|
|
164
|
+
) -> tuple[str, ...]:
|
|
165
|
+
value = table.get(key, list(default))
|
|
166
|
+
if not isinstance(value, list) or not all(isinstance(item, str) for item in value):
|
|
167
|
+
raise ConfigurationError(f"{section}.{key} must be a list of strings")
|
|
168
|
+
return tuple(value)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _optional_str(
|
|
172
|
+
table: Mapping[str, Any], key: str, default: str | None, section: str
|
|
173
|
+
) -> str | None:
|
|
174
|
+
value = table.get(key, default)
|
|
175
|
+
if value is not None and not isinstance(value, str):
|
|
176
|
+
raise ConfigurationError(f"{section}.{key} must be a string")
|
|
177
|
+
return value
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _parse_filesystem(table: Mapping[str, Any], warnings: list[str]) -> FilesystemConfig:
|
|
181
|
+
_unknown_keys("filesystem", table, warnings)
|
|
182
|
+
config = FilesystemConfig(
|
|
183
|
+
enabled=_bool(table, "enabled", True, "filesystem"),
|
|
184
|
+
watch=_str_list(table, "watch", (), "filesystem"),
|
|
185
|
+
ignore=_str_list(table, "ignore", DEFAULT_IGNORE_PATTERNS, "filesystem"),
|
|
186
|
+
max_files=_int(table, "max_files", 100_000, "filesystem"),
|
|
187
|
+
poll_interval=_float(table, "poll_interval", 1.0, "filesystem"),
|
|
188
|
+
native_events=_bool(table, "native_events", True, "filesystem"),
|
|
189
|
+
)
|
|
190
|
+
if config.max_files < 1:
|
|
191
|
+
raise ConfigurationError("filesystem.max_files must be at least 1")
|
|
192
|
+
if config.poll_interval <= 0:
|
|
193
|
+
raise ConfigurationError("filesystem.poll_interval must be greater than zero")
|
|
194
|
+
return config
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _parse_processes(table: Mapping[str, Any], warnings: list[str]) -> ProcessesConfig:
|
|
198
|
+
_unknown_keys("processes", table, warnings)
|
|
199
|
+
config = ProcessesConfig(
|
|
200
|
+
enabled=_bool(table, "enabled", True, "processes"),
|
|
201
|
+
poll_interval=_float(table, "poll_interval", 0.5, "processes"),
|
|
202
|
+
)
|
|
203
|
+
if config.poll_interval <= 0:
|
|
204
|
+
raise ConfigurationError("processes.poll_interval must be greater than zero")
|
|
205
|
+
return config
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _parse_network(table: Mapping[str, Any], warnings: list[str]) -> NetworkConfig:
|
|
209
|
+
_unknown_keys("network", table, warnings)
|
|
210
|
+
config = NetworkConfig(
|
|
211
|
+
enabled=_bool(table, "enabled", True, "network"),
|
|
212
|
+
poll_interval=_float(table, "poll_interval", 0.25, "network"),
|
|
213
|
+
)
|
|
214
|
+
if config.poll_interval <= 0:
|
|
215
|
+
raise ConfigurationError("network.poll_interval must be greater than zero")
|
|
216
|
+
return config
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _parse_privacy(table: Mapping[str, Any], warnings: list[str]) -> PrivacyConfig:
|
|
220
|
+
_unknown_keys("privacy", table, warnings)
|
|
221
|
+
return PrivacyConfig(
|
|
222
|
+
capture_environment=_bool(table, "capture_environment", True, "privacy"),
|
|
223
|
+
show_environment=_bool(table, "show_environment", False, "privacy"),
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _parse_storage(
|
|
228
|
+
table: Mapping[str, Any],
|
|
229
|
+
warnings: list[str],
|
|
230
|
+
base_dir: Path,
|
|
231
|
+
) -> StorageConfig:
|
|
232
|
+
_unknown_keys("storage", table, warnings)
|
|
233
|
+
database_value = _optional_str(table, "database", None, "storage")
|
|
234
|
+
database: Path | None = None
|
|
235
|
+
if database_value:
|
|
236
|
+
database = Path(database_value).expanduser()
|
|
237
|
+
if not database.is_absolute():
|
|
238
|
+
database = base_dir / database
|
|
239
|
+
database = database.resolve()
|
|
240
|
+
config = StorageConfig(
|
|
241
|
+
database=database,
|
|
242
|
+
max_sessions=_int(table, "max_sessions", 0, "storage"),
|
|
243
|
+
max_events=_int(table, "max_events", 100_000, "storage"),
|
|
244
|
+
)
|
|
245
|
+
if config.max_sessions < 0:
|
|
246
|
+
raise ConfigurationError("storage.max_sessions must be zero or greater")
|
|
247
|
+
if config.max_events < 1:
|
|
248
|
+
raise ConfigurationError("storage.max_events must be at least 1")
|
|
249
|
+
return config
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _parse_report(table: Mapping[str, Any], warnings: list[str]) -> ReportConfig:
|
|
253
|
+
_unknown_keys("report", table, warnings)
|
|
254
|
+
format_value = _optional_str(table, "format", "terminal", "report") or "terminal"
|
|
255
|
+
if format_value not in {"terminal", "json", "html"}:
|
|
256
|
+
raise ConfigurationError("report.format must be terminal, json, or html")
|
|
257
|
+
config = ReportConfig(
|
|
258
|
+
max_events=_int(table, "max_events", 500, "report"),
|
|
259
|
+
default_format=format_value,
|
|
260
|
+
)
|
|
261
|
+
if config.max_events < 1:
|
|
262
|
+
raise ConfigurationError("report.max_events must be at least 1")
|
|
263
|
+
return config
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _parse_config(
|
|
267
|
+
data: Mapping[str, Any],
|
|
268
|
+
*,
|
|
269
|
+
source_path: Path | None,
|
|
270
|
+
base_dir: Path,
|
|
271
|
+
) -> Config:
|
|
272
|
+
warnings: list[str] = []
|
|
273
|
+
for key in sorted(set(data) - _ALLOWED_TOP_LEVEL):
|
|
274
|
+
warnings.append(f"unknown configuration section ignored: {key}")
|
|
275
|
+
|
|
276
|
+
filesystem = _parse_filesystem(
|
|
277
|
+
_require_table(data.get("filesystem", {}), "filesystem"), warnings
|
|
278
|
+
)
|
|
279
|
+
processes = _parse_processes(_require_table(data.get("processes", {}), "processes"), warnings)
|
|
280
|
+
network = _parse_network(_require_table(data.get("network", {}), "network"), warnings)
|
|
281
|
+
privacy = _parse_privacy(_require_table(data.get("privacy", {}), "privacy"), warnings)
|
|
282
|
+
storage = _parse_storage(_require_table(data.get("storage", {}), "storage"), warnings, base_dir)
|
|
283
|
+
report = _parse_report(_require_table(data.get("report", {}), "report"), warnings)
|
|
284
|
+
|
|
285
|
+
return Config(
|
|
286
|
+
filesystem=filesystem,
|
|
287
|
+
processes=processes,
|
|
288
|
+
network=network,
|
|
289
|
+
privacy=privacy,
|
|
290
|
+
storage=storage,
|
|
291
|
+
report=report,
|
|
292
|
+
source_path=source_path,
|
|
293
|
+
base_dir=base_dir,
|
|
294
|
+
warnings=tuple(warnings),
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def load_config(
|
|
299
|
+
explicit: Path | None = None,
|
|
300
|
+
*,
|
|
301
|
+
cwd: Path | None = None,
|
|
302
|
+
environment: Mapping[str, str] | None = None,
|
|
303
|
+
) -> Config:
|
|
304
|
+
"""Load configuration from the first available location.
|
|
305
|
+
|
|
306
|
+
Relative paths inside a configuration file resolve against that file's
|
|
307
|
+
directory. A missing explicit file is an error; no implicit file is fine.
|
|
308
|
+
"""
|
|
309
|
+
|
|
310
|
+
working_dir = (cwd or Path.cwd()).resolve()
|
|
311
|
+
path = find_config_path(explicit, cwd=working_dir, environment=environment)
|
|
312
|
+
if path is None:
|
|
313
|
+
return Config(base_dir=working_dir)
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
raw = path.read_text(encoding="utf-8")
|
|
317
|
+
data = tomllib.loads(raw)
|
|
318
|
+
except tomllib.TOMLDecodeError as exc:
|
|
319
|
+
raise ConfigurationError(f"invalid TOML in {path}: {exc}") from exc
|
|
320
|
+
except OSError as exc:
|
|
321
|
+
raise ConfigurationError(f"cannot read configuration file {path}: {exc}") from exc
|
|
322
|
+
|
|
323
|
+
return _parse_config(data, source_path=path, base_dir=path.parent)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def resolve_watch_roots(
|
|
327
|
+
config: Config,
|
|
328
|
+
command_line_roots: tuple[str, ...],
|
|
329
|
+
*,
|
|
330
|
+
cwd: Path,
|
|
331
|
+
) -> tuple[Path, ...]:
|
|
332
|
+
"""Resolve, validate, deduplicate, and collapse watch roots.
|
|
333
|
+
|
|
334
|
+
CLI paths are relative to the invocation directory. Paths from a config file
|
|
335
|
+
are relative to the configuration file. Overlapping roots are collapsed to
|
|
336
|
+
the parent so the same event is not observed twice.
|
|
337
|
+
"""
|
|
338
|
+
|
|
339
|
+
if command_line_roots:
|
|
340
|
+
raw_roots = command_line_roots
|
|
341
|
+
base = cwd
|
|
342
|
+
elif config.filesystem.watch:
|
|
343
|
+
raw_roots = config.filesystem.watch
|
|
344
|
+
base = config.base_dir
|
|
345
|
+
else:
|
|
346
|
+
raw_roots = (".",)
|
|
347
|
+
base = cwd
|
|
348
|
+
|
|
349
|
+
resolved: list[Path] = []
|
|
350
|
+
for raw in raw_roots:
|
|
351
|
+
candidate = Path(raw).expanduser()
|
|
352
|
+
if not candidate.is_absolute():
|
|
353
|
+
candidate = base / candidate
|
|
354
|
+
candidate = candidate.resolve()
|
|
355
|
+
if not candidate.exists():
|
|
356
|
+
raise ConfigurationError(f"watch root does not exist: {candidate}")
|
|
357
|
+
if not candidate.is_dir():
|
|
358
|
+
raise ConfigurationError(f"watch root is not a directory: {candidate}")
|
|
359
|
+
resolved.append(candidate)
|
|
360
|
+
|
|
361
|
+
unique: list[Path] = []
|
|
362
|
+
seen: set[str] = set()
|
|
363
|
+
for root in resolved:
|
|
364
|
+
key = os.path.normcase(str(root))
|
|
365
|
+
if key not in seen:
|
|
366
|
+
seen.add(key)
|
|
367
|
+
unique.append(root)
|
|
368
|
+
|
|
369
|
+
collapsed: list[Path] = []
|
|
370
|
+
for root in unique:
|
|
371
|
+
if any(root != other and root.is_relative_to(other) for other in unique):
|
|
372
|
+
continue
|
|
373
|
+
collapsed.append(root)
|
|
374
|
+
return tuple(collapsed)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def default_config_text() -> str:
|
|
378
|
+
"""Return a commented TOML template for ``cmdscope config --init``."""
|
|
379
|
+
|
|
380
|
+
ignored = ", ".join(f'"{pattern}"' for pattern in DEFAULT_IGNORE_PATTERNS)
|
|
381
|
+
return f"""# CmdScope configuration
|
|
382
|
+
# All settings are optional. Relative paths resolve against this file.
|
|
383
|
+
|
|
384
|
+
[filesystem]
|
|
385
|
+
enabled = true
|
|
386
|
+
# Empty means the current working directory.
|
|
387
|
+
watch = []
|
|
388
|
+
ignore = [{ignored}]
|
|
389
|
+
max_files = 100000
|
|
390
|
+
poll_interval = 1.0
|
|
391
|
+
# Use native OS events (ReadDirectoryChangesW/inotify/FSEvents) when possible.
|
|
392
|
+
native_events = true
|
|
393
|
+
|
|
394
|
+
[processes]
|
|
395
|
+
enabled = true
|
|
396
|
+
poll_interval = 0.5
|
|
397
|
+
|
|
398
|
+
[network]
|
|
399
|
+
enabled = true
|
|
400
|
+
poll_interval = 0.25
|
|
401
|
+
|
|
402
|
+
[privacy]
|
|
403
|
+
# Store only a sanitized diff of the initial child environment.
|
|
404
|
+
capture_environment = true
|
|
405
|
+
# Do not include environment keys in reports by default.
|
|
406
|
+
show_environment = false
|
|
407
|
+
|
|
408
|
+
[storage]
|
|
409
|
+
# Defaults to the per-user data directory.
|
|
410
|
+
# database = "cmdscope.db"
|
|
411
|
+
# 0 keeps all sessions.
|
|
412
|
+
max_sessions = 0
|
|
413
|
+
max_events = 100000
|
|
414
|
+
|
|
415
|
+
[report]
|
|
416
|
+
max_events = 500
|
|
417
|
+
format = "terminal"
|
|
418
|
+
"""
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def config_as_dict(config: Config) -> dict[str, Any]:
|
|
422
|
+
"""Return a JSON-friendly representation of the effective configuration."""
|
|
423
|
+
|
|
424
|
+
def database(value: Path | None) -> str | None:
|
|
425
|
+
return str(value) if value is not None else None
|
|
426
|
+
|
|
427
|
+
return {
|
|
428
|
+
"source_path": str(config.source_path) if config.source_path else None,
|
|
429
|
+
"base_dir": str(config.base_dir),
|
|
430
|
+
"filesystem": {
|
|
431
|
+
"enabled": config.filesystem.enabled,
|
|
432
|
+
"watch": list(config.filesystem.watch),
|
|
433
|
+
"ignore": list(config.filesystem.ignore),
|
|
434
|
+
"max_files": config.filesystem.max_files,
|
|
435
|
+
"poll_interval": config.filesystem.poll_interval,
|
|
436
|
+
"native_events": config.filesystem.native_events,
|
|
437
|
+
},
|
|
438
|
+
"processes": {
|
|
439
|
+
"enabled": config.processes.enabled,
|
|
440
|
+
"poll_interval": config.processes.poll_interval,
|
|
441
|
+
},
|
|
442
|
+
"network": {
|
|
443
|
+
"enabled": config.network.enabled,
|
|
444
|
+
"poll_interval": config.network.poll_interval,
|
|
445
|
+
},
|
|
446
|
+
"privacy": {
|
|
447
|
+
"capture_environment": config.privacy.capture_environment,
|
|
448
|
+
"show_environment": config.privacy.show_environment,
|
|
449
|
+
},
|
|
450
|
+
"storage": {
|
|
451
|
+
"database": database(config.storage.database),
|
|
452
|
+
"effective_database": str(config.database_path()),
|
|
453
|
+
"max_sessions": config.storage.max_sessions,
|
|
454
|
+
"max_events": config.storage.max_events,
|
|
455
|
+
},
|
|
456
|
+
"report": {
|
|
457
|
+
"max_events": config.report.max_events,
|
|
458
|
+
"format": config.report.default_format,
|
|
459
|
+
},
|
|
460
|
+
"warnings": list(config.warnings),
|
|
461
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core models and interfaces."""
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Collector interface and the immutable context passed to collectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from cmdscope.core.events import CollectorStatus, Event
|
|
12
|
+
from cmdscope.core.ignore import IgnoreRules
|
|
13
|
+
from cmdscope.core.registry import ProcessRegistry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class CollectorContext:
|
|
18
|
+
"""Everything a collector needs to observe one session.
|
|
19
|
+
|
|
20
|
+
Collectors must use ``emit`` for observations and must never write directly
|
|
21
|
+
to storage, reports, or the terminal.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
session_id: int
|
|
25
|
+
watch_roots: tuple[Path, ...]
|
|
26
|
+
ignore: IgnoreRules
|
|
27
|
+
root_pid: int
|
|
28
|
+
command_name: str
|
|
29
|
+
registry: ProcessRegistry
|
|
30
|
+
emit: Callable[[Event], None]
|
|
31
|
+
filesystem_poll_interval: float = 1.0
|
|
32
|
+
process_poll_interval: float = 0.5
|
|
33
|
+
network_poll_interval: float = 0.25
|
|
34
|
+
max_files: int = 100_000
|
|
35
|
+
use_native_events: bool = True
|
|
36
|
+
started_at: datetime | None = None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Collector(Protocol):
|
|
40
|
+
"""Minimal lifecycle implemented by every collector."""
|
|
41
|
+
|
|
42
|
+
name: str
|
|
43
|
+
|
|
44
|
+
def start(self, context: CollectorContext) -> None:
|
|
45
|
+
"""Begin observing. Must perform the baseline before the command starts."""
|
|
46
|
+
|
|
47
|
+
def stop(self) -> CollectorStatus:
|
|
48
|
+
"""Stop observing, reconcile, and return an honest status."""
|