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,33 @@
1
+ """Thread-safe registry of PIDs observed for the current session."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from threading import Lock
6
+
7
+
8
+ class ProcessRegistry:
9
+ """Share the observed process set between process and network collectors.
10
+
11
+ Network attribution depends on this registry. A PID that was never observed
12
+ is never attributed to the session, even if it holds a connection.
13
+ """
14
+
15
+ def __init__(self) -> None:
16
+ self._lock = Lock()
17
+ self._names: dict[int, str] = {}
18
+
19
+ def add(self, pid: int, name: str) -> None:
20
+ with self._lock:
21
+ self._names[pid] = name
22
+
23
+ def remove(self, pid: int) -> None:
24
+ with self._lock:
25
+ self._names.pop(pid, None)
26
+
27
+ def snapshot(self) -> dict[int, str]:
28
+ with self._lock:
29
+ return dict(self._names)
30
+
31
+ def __len__(self) -> int:
32
+ with self._lock:
33
+ return len(self._names)
@@ -0,0 +1,81 @@
1
+ """Session and summary models."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime
7
+
8
+ from cmdscope.core.events import CollectorStatus, JSONValue, SessionStatus
9
+
10
+
11
+ def session_ref(session_id: int) -> str:
12
+ """Format a database session id for humans (``S000001``)."""
13
+
14
+ if session_id <= 0:
15
+ raise ValueError("session id must be positive")
16
+ return f"S{session_id:06d}"
17
+
18
+
19
+ def parse_session_ref(value: str) -> int:
20
+ """Accept ``1``, ``S1``, or ``S000001`` and return the numeric id."""
21
+
22
+ text = value.strip()
23
+ if text[:1].lower() == "s":
24
+ text = text[1:]
25
+ if not text.isdigit() or int(text) <= 0:
26
+ raise ValueError(f"invalid session reference: {value!r}")
27
+ return int(text)
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class Session:
32
+ """Persisted metadata for one monitored execution."""
33
+
34
+ id: int
35
+ status: SessionStatus
36
+ command: tuple[str, ...]
37
+ command_display: str
38
+ cwd: str
39
+ platform_name: str
40
+ created_at: datetime
41
+ started_at: datetime | None
42
+ ended_at: datetime | None
43
+ duration_s: float | None
44
+ exit_code: int | None
45
+ interrupted: bool
46
+ error: str | None
47
+ env_diff: dict[str, JSONValue]
48
+ options: dict[str, JSONValue]
49
+ collectors: tuple[CollectorStatus, ...] = ()
50
+
51
+ @property
52
+ def ref(self) -> str:
53
+ return session_ref(self.id)
54
+
55
+
56
+ @dataclass(frozen=True, slots=True)
57
+ class SessionSummary:
58
+ """Counts derived exclusively from stored events and collector status."""
59
+
60
+ session: Session
61
+ events_total: int
62
+ filesystem_created: int
63
+ filesystem_modified: int
64
+ filesystem_deleted: int
65
+ processes_started: int
66
+ processes_finished: int
67
+ processes_still_running: int
68
+ network_connections: int
69
+ warnings: tuple[str, ...] = field(default_factory=tuple)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class RunResult:
74
+ """Result returned by the session runner."""
75
+
76
+ session_id: int
77
+ exit_code: int
78
+ interrupted: bool
79
+ event_count: int
80
+ events_truncated: bool
81
+ summary: SessionSummary
@@ -0,0 +1,183 @@
1
+ """Read-only environment diagnostics for ``cmdscope doctor``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib.metadata
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import psutil
12
+
13
+ from cmdscope import __version__
14
+ from cmdscope.config import Config
15
+ from cmdscope.core.session import session_ref
16
+ from cmdscope.errors import StorageError
17
+ from cmdscope.platforms import current_platform_info, is_elevated
18
+ from cmdscope.storage.sqlite import Storage
19
+
20
+
21
+ def _package_version(name: str) -> str:
22
+ try:
23
+ return importlib.metadata.version(name)
24
+ except importlib.metadata.PackageNotFoundError:
25
+ return "not installed"
26
+
27
+
28
+ def _watchdog_info() -> dict[str, Any]:
29
+ try:
30
+ from watchdog.observers import Observer
31
+
32
+ observer = Observer()
33
+ return {
34
+ "available": True,
35
+ "observer": type(observer).__name__,
36
+ "error": None,
37
+ }
38
+ except Exception as exc:
39
+ return {"available": False, "observer": None, "error": str(exc)}
40
+
41
+
42
+ def _network_sample() -> dict[str, Any]:
43
+ try:
44
+ connections = psutil.net_connections(kind="inet")
45
+ except (psutil.AccessDenied, PermissionError) as exc:
46
+ return {"available": False, "count": None, "error": str(exc)}
47
+ except OSError as exc:
48
+ return {"available": False, "count": None, "error": str(exc)}
49
+ attributed = sum(1 for connection in connections if getattr(connection, "pid", None))
50
+ return {"available": True, "count": len(connections), "with_pid": attributed, "error": None}
51
+
52
+
53
+ def _database_info(path: Path) -> dict[str, Any]:
54
+ result: dict[str, Any] = {
55
+ "path": str(path),
56
+ "parent_writable": os.access(path.parent, os.W_OK),
57
+ "opened": False,
58
+ "schema_version": None,
59
+ "quick_check": None,
60
+ "size_bytes": None,
61
+ "error": None,
62
+ }
63
+ try:
64
+ with Storage(path) as storage:
65
+ result["opened"] = True
66
+ result["schema_version"] = storage.schema_version()
67
+ result["quick_check"] = storage.quick_check()
68
+ result["size_bytes"] = storage.storage_size_bytes()
69
+ except StorageError as exc:
70
+ result["error"] = str(exc)
71
+ return result
72
+
73
+
74
+ def collect_diagnostics(config: Config, database: Path) -> dict[str, Any]:
75
+ """Collect diagnostics without changing machine configuration."""
76
+
77
+ info = current_platform_info()
78
+ inotify_limit: int | None = None
79
+ if info.name == "linux":
80
+ try:
81
+ inotify_limit = int(Path("/proc/sys/fs/inotify/max_user_watches").read_text().strip())
82
+ except (OSError, ValueError):
83
+ inotify_limit = None
84
+
85
+ diagnostics: dict[str, Any] = {
86
+ "cmdscope_version": __version__,
87
+ "python_version": sys.version.split()[0],
88
+ "executable": sys.executable,
89
+ "platform": {
90
+ "name": info.name,
91
+ "display_name": info.display_name,
92
+ "filesystem_backend": info.fs_backend,
93
+ "native_filesystem_events": info.native_filesystem_events,
94
+ "data_dir": str(info.default_data_dir),
95
+ "config_dir": str(info.default_config_dir),
96
+ "default_database": str(info.default_database_path),
97
+ },
98
+ "user": {
99
+ "elevated": is_elevated(),
100
+ "note": "CmdScope never elevates privileges automatically.",
101
+ },
102
+ "dependencies": {
103
+ "psutil": _package_version("psutil"),
104
+ "watchdog": _package_version("watchdog"),
105
+ },
106
+ "watchdog": _watchdog_info(),
107
+ "network": _network_sample(),
108
+ "database": _database_info(database),
109
+ "configuration": {
110
+ "source_path": str(config.source_path) if config.source_path else None,
111
+ "warnings": list(config.warnings),
112
+ },
113
+ "inotify_max_user_watches": inotify_limit,
114
+ "limitations": list(info.notes),
115
+ }
116
+ return diagnostics
117
+
118
+
119
+ def render_diagnostics(data: dict[str, Any]) -> str:
120
+ """Render diagnostics as a readable terminal report."""
121
+
122
+ platform = data.get("platform", {})
123
+ user = data.get("user", {})
124
+ dependencies = data.get("dependencies", {})
125
+ watchdog = data.get("watchdog", {})
126
+ network = data.get("network", {})
127
+ database = data.get("database", {})
128
+ configuration = data.get("configuration", {})
129
+ lines = [
130
+ f"CMDSCOPE - DIAGNOSTICS (v{data.get('cmdscope_version')})",
131
+ "=" * 70,
132
+ f"Python: {data.get('python_version')} ({data.get('executable')})",
133
+ f"Platform: {platform.get('display_name')} [{platform.get('name')}]",
134
+ f"Filesystem: {platform.get('filesystem_backend')}",
135
+ f"Data directory: {platform.get('data_dir')}",
136
+ f"Config directory:{platform.get('config_dir')}",
137
+ "",
138
+ f"psutil: {dependencies.get('psutil')}",
139
+ f"watchdog: {dependencies.get('watchdog')}",
140
+ f"Observer: {watchdog.get('observer') or watchdog.get('error')}",
141
+ f"Elevated: {'yes' if user.get('elevated') else 'no'}",
142
+ "",
143
+ f"Database: {database.get('path')}",
144
+ f" opened: {database.get('opened')}",
145
+ f" schema: {database.get('schema_version')}",
146
+ f" quick check: {database.get('quick_check')}",
147
+ f" size (bytes): {database.get('size_bytes')}",
148
+ f" parent writable: {database.get('parent_writable')}",
149
+ ]
150
+ if database.get("error"):
151
+ lines.append(f" error: {database['error']}")
152
+ lines.extend(
153
+ [
154
+ "",
155
+ f"Network sample: {network.get('count')} inet sockets "
156
+ f"({network.get('with_pid')} with a PID)",
157
+ f" available: {network.get('available')}",
158
+ ]
159
+ )
160
+ if network.get("error"):
161
+ lines.append(f" error: {network['error']}")
162
+ if data.get("inotify_max_user_watches") is not None:
163
+ lines.append(f"inotify watches: {data['inotify_max_user_watches']}")
164
+ lines.extend(
165
+ [
166
+ "",
167
+ f"Config source: {configuration.get('source_path') or 'none (defaults)'}",
168
+ ]
169
+ )
170
+ for warning in configuration.get("warnings", []):
171
+ lines.append(f" warning: {warning}")
172
+ lines.extend(["", "LIMITATIONS"])
173
+ for note in data.get("limitations", []):
174
+ lines.append(f" - {note}")
175
+ lines.append("")
176
+ lines.append("No changes were made to the system. No data was transmitted.")
177
+ return "\n".join(lines)
178
+
179
+
180
+ def session_label(session_id: int) -> str:
181
+ """Convenience for diagnostics callers; kept tiny and tested."""
182
+
183
+ return session_ref(session_id)
cmdscope/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """Domain exceptions with messages suitable for CLI users."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class CmdScopeError(Exception):
7
+ """Base class for expected CmdScope failures."""
8
+
9
+
10
+ class ConfigurationError(CmdScopeError):
11
+ """The configuration file or command-line configuration is invalid."""
12
+
13
+
14
+ class StorageError(CmdScopeError):
15
+ """The local session database cannot be opened or updated."""
16
+
17
+
18
+ class SessionNotFoundError(CmdScopeError):
19
+ """A requested session does not exist."""
20
+
21
+
22
+ class ReportExistsError(CmdScopeError):
23
+ """A report output path already exists and was not explicitly overwritten."""
24
+
25
+
26
+ class CommandLaunchError(CmdScopeError):
27
+ """The monitored command could not be started."""
28
+
29
+ def __init__(self, message: str, *, exit_code: int = 1, session_id: int | None = None) -> None:
30
+ super().__init__(message)
31
+ self.exit_code = exit_code
32
+ self.session_id = session_id
33
+
34
+
35
+ class EventLimitReached(Exception):
36
+ """Internal signal used by the event writer when a session reaches its limit."""
@@ -0,0 +1,57 @@
1
+ """Platform selection.
2
+
3
+ Only Windows is manually validated for the 0.1 series. Linux and macOS use
4
+ the same portable collectors and are exercised by CI.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from collections.abc import Mapping
12
+
13
+ from cmdscope.platforms import base
14
+ from cmdscope.platforms.base import PlatformInfo
15
+
16
+ __all__ = [
17
+ "PlatformInfo",
18
+ "config_dir",
19
+ "current_platform_info",
20
+ "data_dir",
21
+ "fallback_info",
22
+ "is_elevated",
23
+ "prepare_command",
24
+ ]
25
+
26
+
27
+ def current_platform_info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
28
+ """Return capabilities for the host running CmdScope."""
29
+
30
+ env = os.environ if environment is None else environment
31
+ platform_key = _platform_key()
32
+ if platform_key.startswith("win"):
33
+ from cmdscope.platforms import windows
34
+
35
+ return windows.info(env)
36
+ if platform_key == "darwin":
37
+ from cmdscope.platforms import macos
38
+
39
+ return macos.info(env)
40
+ if platform_key.startswith("linux"):
41
+ from cmdscope.platforms import linux
42
+
43
+ return linux.info(env)
44
+ return base.fallback_info(env)
45
+
46
+
47
+ def _platform_key() -> str:
48
+ """Indirection keeps static analyzers from folding platform branches."""
49
+
50
+ return sys.platform
51
+
52
+
53
+ config_dir = base.config_dir
54
+ data_dir = base.data_dir
55
+ fallback_info = base.fallback_info
56
+ is_elevated = base.is_elevated
57
+ prepare_command = base.prepare_command
@@ -0,0 +1,140 @@
1
+ """Platform-neutral capability and path helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from collections.abc import Mapping, Sequence
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+
14
+
15
+ @dataclass(frozen=True, slots=True)
16
+ class PlatformInfo:
17
+ """Static, honest description of what the current platform can observe."""
18
+
19
+ name: str
20
+ display_name: str
21
+ fs_backend: str
22
+ native_filesystem_events: bool
23
+ default_data_dir: Path
24
+ default_config_dir: Path
25
+ notes: tuple[str, ...]
26
+
27
+ @property
28
+ def default_database_path(self) -> Path:
29
+ return self.default_data_dir / "cmdscope.db"
30
+
31
+
32
+ def _home(environment: Mapping[str, str], platform_name: str) -> Path:
33
+ keys = ("USERPROFILE", "HOME") if platform_name == "windows" else ("HOME", "USERPROFILE")
34
+ for key in keys:
35
+ value = environment.get(key)
36
+ if value:
37
+ return Path(value).expanduser()
38
+ return Path.home()
39
+
40
+
41
+ def data_dir(platform_name: str, environment: Mapping[str, str] | None = None) -> Path:
42
+ """Return the per-user data directory for *platform_name*.
43
+
44
+ The functions accept an environment mapping so tests can validate path
45
+ behavior on any host without touching the real user profile.
46
+ """
47
+
48
+ env = os.environ if environment is None else environment
49
+ home = _home(env, platform_name)
50
+ if platform_name == "windows":
51
+ base = env.get("LOCALAPPDATA")
52
+ return (
53
+ Path(base).expanduser() / "cmdscope"
54
+ if base
55
+ else home / "AppData" / "Local" / "cmdscope"
56
+ )
57
+ if platform_name == "macos":
58
+ return home / "Library" / "Application Support" / "cmdscope"
59
+ base = env.get("XDG_DATA_HOME")
60
+ return Path(base).expanduser() / "cmdscope" if base else home / ".local" / "share" / "cmdscope"
61
+
62
+
63
+ def config_dir(platform_name: str, environment: Mapping[str, str] | None = None) -> Path:
64
+ """Return the per-user configuration directory for *platform_name*."""
65
+
66
+ env = os.environ if environment is None else environment
67
+ home = _home(env, platform_name)
68
+ if platform_name == "windows":
69
+ base = env.get("APPDATA")
70
+ return (
71
+ Path(base).expanduser() / "cmdscope"
72
+ if base
73
+ else home / "AppData" / "Roaming" / "cmdscope"
74
+ )
75
+ if platform_name == "macos":
76
+ return home / "Library" / "Application Support" / "cmdscope"
77
+ base = env.get("XDG_CONFIG_HOME")
78
+ return Path(base).expanduser() / "cmdscope" if base else home / ".config" / "cmdscope"
79
+
80
+
81
+ def fallback_info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
82
+ """Conservative capabilities for an unrecognized POSIX-like platform."""
83
+
84
+ env = os.environ if environment is None else environment
85
+ return PlatformInfo(
86
+ name="unknown",
87
+ display_name=f"Unknown ({sys.platform})",
88
+ fs_backend="polling",
89
+ native_filesystem_events=False,
90
+ default_data_dir=data_dir("linux", env),
91
+ default_config_dir=config_dir("linux", env),
92
+ notes=(
93
+ "This platform is not officially supported. CmdScope uses portable polling.",
94
+ "Only filesystem snapshots inside watch roots are available.",
95
+ ),
96
+ )
97
+
98
+
99
+ def prepare_command(
100
+ argv: Sequence[str],
101
+ environment: Mapping[str, str] | None = None,
102
+ ) -> list[str]:
103
+ """Prepare *argv* for ``subprocess.Popen`` without using ``shell=True``.
104
+
105
+ Windows batch shims (``.cmd``/``.bat``) are not directly executable by
106
+ ``CreateProcess``. For those, CmdScope intentionally launches the user's
107
+ command through ``cmd.exe /c`` using ``subprocess.list2cmdline`` quoting.
108
+ This is the only shell indirection in the project and it is documented.
109
+ """
110
+
111
+ if not argv:
112
+ raise ValueError("command must not be empty")
113
+
114
+ env = os.environ if environment is None else environment
115
+ if os.name != "nt":
116
+ return list(argv)
117
+
118
+ executable = shutil.which(argv[0], path=env.get("PATH")) or argv[0]
119
+ if executable.lower().endswith((".cmd", ".bat")):
120
+ comspec = env.get("COMSPEC") or "cmd.exe"
121
+ return [comspec, "/d", "/s", "/c", subprocess.list2cmdline(list(argv))]
122
+ return list(argv)
123
+
124
+
125
+ def is_elevated() -> bool:
126
+ """Return whether the current process is elevated. Never attempts elevation."""
127
+
128
+ if os.name == "nt":
129
+ try:
130
+ ctypes = importlib.import_module("ctypes")
131
+ windll = getattr(ctypes, "windll", None)
132
+ if windll is None: # pragma: no cover - non-Windows typeshed
133
+ return False
134
+ return bool(windll.shell32.IsUserAnAdmin())
135
+ except (AttributeError, OSError):
136
+ return False
137
+ geteuid = getattr(os, "geteuid", None)
138
+ if geteuid is None: # pragma: no cover - non-POSIX non-Windows host
139
+ return False
140
+ return geteuid() == 0
@@ -0,0 +1,28 @@
1
+ """Linux platform capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+ from cmdscope.platforms.base import PlatformInfo, config_dir, data_dir
9
+
10
+
11
+ def info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
12
+ env = os.environ if environment is None else environment
13
+ return PlatformInfo(
14
+ name="linux",
15
+ display_name="Linux",
16
+ fs_backend="inotify (watchdog)",
17
+ native_filesystem_events=True,
18
+ default_data_dir=data_dir("linux", env),
19
+ default_config_dir=config_dir("linux", env),
20
+ notes=(
21
+ "Filesystem events cover only the configured watch roots, recursively.",
22
+ "inotify watch limits can reduce coverage; CmdScope reports the degraded state.",
23
+ "Processes are discovered by polling; very short-lived children can be missed.",
24
+ "Network endpoint ownership for other users may require root privileges.",
25
+ "eBPF, auditd, and ptrace-based syscall tracing are intentionally not used.",
26
+ "CmdScope is not a sandbox and does not restrict the monitored command.",
27
+ ),
28
+ )
@@ -0,0 +1,26 @@
1
+ """macOS platform capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+ from cmdscope.platforms.base import PlatformInfo, config_dir, data_dir
9
+
10
+
11
+ def info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
12
+ env = os.environ if environment is None else environment
13
+ return PlatformInfo(
14
+ name="macos",
15
+ display_name="macOS",
16
+ fs_backend="FSEvents/kqueue (watchdog)",
17
+ native_filesystem_events=True,
18
+ default_data_dir=data_dir("macos", env),
19
+ default_config_dir=config_dir("macos", env),
20
+ notes=(
21
+ "Filesystem events cover only the configured watch roots, recursively.",
22
+ "Processes are discovered by polling; very short-lived children can be missed.",
23
+ "Network endpoint ownership for other users may require root privileges.",
24
+ "CmdScope is not a sandbox and does not restrict the monitored command.",
25
+ ),
26
+ )
@@ -0,0 +1,28 @@
1
+ """Windows platform capabilities."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from collections.abc import Mapping
7
+
8
+ from cmdscope.platforms.base import PlatformInfo, config_dir, data_dir
9
+
10
+
11
+ def info(environment: Mapping[str, str] | None = None) -> PlatformInfo:
12
+ env = os.environ if environment is None else environment
13
+ return PlatformInfo(
14
+ name="windows",
15
+ display_name="Windows",
16
+ fs_backend="ReadDirectoryChangesW (watchdog)",
17
+ native_filesystem_events=True,
18
+ default_data_dir=data_dir("windows", env),
19
+ default_config_dir=config_dir("windows", env),
20
+ notes=(
21
+ "Filesystem events cover only the configured watch roots, recursively.",
22
+ "Processes are discovered by polling; very short-lived children can be missed.",
23
+ "Network endpoints come from psutil polling; ownership may be incomplete for "
24
+ "processes owned by other users.",
25
+ "Environment mutations inside a running process cannot be observed externally.",
26
+ "CmdScope is not a sandbox and does not restrict the monitored command.",
27
+ ),
28
+ )